Freeframe

Documentation

Agency

v1.1.6Updated Oct 18, 2026A bold, high-end website for a creative or digital agency.

Overview#

Agency is a complete website for a creative or digital agency, built for the fictional Northbound Studio (London, Lisbon and New York): a big-type home page with a rotating word, marquee and testimonial carousel, services with engagement models and price ranges, a filterable work page, a full case study with a before/after slider, an about page with team portraits, a careers page with filterable roles and an application form, and a detailed project-brief contact form. This is the Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4 version, version 1.1.4. It exports to static files. See it live at freeframe.dev/templates/agency.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint
pnpm typecheck Generate route types, then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/agency into demo/ (only used for the freeframe.dev demo)

Pages#

Route File What's on it
/ src/app/page.tsx Line-by-line headline with a rotating word, featured project, services marquee, client logos, services, selected work, process, count-up numbers and awards, testimonial carousel
/services src/app/services/page.tsx Four services with deliverables and timelines, engagement models with price ranges, FAQ
/work src/app/work/page.tsx All case studies with an industry filter; /work#fintech opens it filtered
/case-study src/app/case-study/page.tsx Kite Mobility: facts, challenge, before/after slider, approach, brand system, app screens, results, quote, credits, next project
/about src/app/about/page.tsx Studio illustration, story timeline, values, team portraits
/careers src/app/careers/page.tsx Perks, open roles with a team filter and expandable details, application form
/contact src/app/contact/page.tsx Project-brief form, email addresses, studios with live local times
404 src/app/not-found.tsx Not-found page

Project structure#

agency/
├─ src/
│  ├─ app/
│  │  ├─ layout.tsx            fonts, site-wide metadata, header, footer, reveal controller, back to top
│  │  ├─ globals.css           brand tokens (@theme) and component classes
│  │  ├─ icon.svg              favicon
│  │  ├─ page.tsx              home page (also holds the stats, process, awards and logos lists)
│  │  ├─ <route>/page.tsx      services, work, case-study, about, careers, contact
│  │  └─ not-found.tsx
│  ├─ components/
│  │  ├─ site-header.tsx, site-footer.tsx
│  │  ├─ rotator.tsx           rotating word in the hero
│  │  ├─ testimonial-carousel.tsx
│  │  ├─ work-grid.tsx, case-card.tsx, filter-chips.tsx   work page and its filter
│  │  ├─ careers.tsx           roles board with filter, application form, role preselection
│  │  ├─ contact-form.tsx      project-brief form
│  │  ├─ forms.tsx             shared validation hook, error and success components
│  │  ├─ compare-slider.tsx    before/after slider
│  │  ├─ count-up.tsx, local-time.tsx, page-hero.tsx, icons.tsx
│  │  ├─ reveal-controller.tsx headline and scroll-reveal animations
│  │  └─ back-to-top.tsx
│  ├─ fonts/                    Bricolage Grotesque, Inter, JetBrains Mono (Latin .woff2) and their OFL licences
│  ├─ data/
│  │  ├─ site.ts               studio name, emails, navigation, offices, social links
│  │  ├─ cases.ts              industries and case studies
│  │  ├─ careers.ts            departments, roles and perks
│  │  ├─ services.ts           services, engagement models, FAQ
│  │  └─ team.ts               team members and testimonials
│  └─ lib/
│     ├─ asset.ts              prefixes /public paths with the base path
│     ├─ metadata.ts           pageMetadata() helper for titles and Open Graph
│     └─ use-reduced-motion.ts
├─ public/img/                 SVG artwork, logo and og-image.png
├─ next.config.ts, eslint.config.mjs, postcss.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The brand tokens are the @theme block at the top of src/app/globals.css:

css
@theme {
  --color-paper: #f1efe9;      /* page background */
  --color-paper-2: #e7e4dc;    /* sunken panels */
  --color-ink: #0e0e0c;        /* text and dark sections */
  --color-muted: #5f5c55;      /* secondary text */
  --color-line: #d4d0c5;       /* hairlines on paper */
  --color-cobalt: #2b36f5;     /* primary accent */
  --color-cobalt-600: #1f28c9;
  --color-lime: #d4ff3f;       /* highlight accent, used on dark */
}

They become utilities such as bg-paper, text-ink, text-muted, border-line, text-cobalt and bg-lime. To rebrand, change --color-cobalt, --color-cobalt-600 and --color-lime. The browser theme colour is viewport.themeColor in src/app/layout.tsx. Component classes (.btn, .btn-primary, .btn-outline, .btn-lime, .btn-ghost-dark, .chip, .eyebrow, .display-xl, .display-lg, .field, .label, .field-error, .check-tile, .link-u, .marquee, .on-dark...) live in the same file.

Typography#

Bricolage Grotesque (headings), Inter (body) and JetBrains Mono (labels) are self-hosted with next/font/local in src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML version, so text sets exactly like it (SIL Open Font License texts are the *-OFL.txt files beside them). They are exposed as --font-bricolage, --font-inter and --font-jetbrains; globals.css maps them to --font-display, --font-sans and --font-mono. To swap a font, add its .woff2 (and licence) to src/fonts/ and point src at it, or import a family from next/font/google, and keep the same variable name:

tsx
import { Space_Grotesk } from "next/font/google";
const bricolage = Space_Grotesk({ variable: "--font-bricolage", subsets: ["latin"] });

Fonts are served from your own site, so visitors make no requests to Google.

Content#

Most content lives in typed data files in src/data/:

  • site.ts: site (studio name and email addresses), nav (header links; also lists other routes that highlight a link, e.g. /case-study highlights Work), offices (city, IANA time zone, address) and socials.

  • cases.ts: industries and cases. Add a project:

    ts
    { client: "Lumen Energy", year: 2026, title: "A clearer way to read your energy bill",
      industry: "fintech", services: "Product design", image: "/img/case-lumen.svg", href: "/work#fintech" },

    industry must be one of the Industry values; to add an industry, extend the Industry type and industries. Filter counts update automatically. Only Kite links to a full case study (/case-study); copy src/app/case-study/page.tsx to make more.

  • careers.ts: departments, roles (title, department, location, type, summary, doList, bringList) and perks.

  • services.ts: services, engagementModels (name, duration, price, includes, featured) and serviceFaqs.

  • team.ts: team (name, role, image) and testimonials.

  • Home page lists (disciplines, process, stats, awards, logos) and the rotating words (<Rotator words={[...]} />) are at the top of src/app/page.tsx.

  • Case study details (facts, steps, results, credits) are at the top of src/app/case-study/page.tsx.

Images and artwork#

All artwork is SVG in public/img/. Reference files with asset() from src/lib/asset.ts so they keep working when the site is served from a sub-folder:

tsx
<img src={asset("/img/case-lumen.jpg")} alt="Lumen Energy project artwork" width={1600} height={1000} />

Keep case covers at 1600×1000 (16:10), team portraits at 400×480 (5:6), and the social image (public/img/og-image.png) at 1200×630. .jpg and .webp work fine. The favicon is src/app/icon.svg.

Header links come from nav in src/data/site.ts (used by the desktop and mobile menus). The header is src/components/site-header.tsx; the footer (offices with live times, social links) is src/components/site-footer.tsx. To add a page, create src/app/<route>/page.tsx with export const metadata = pageMetadata("Title", "Description") and add it to nav.

Features#

Header and mobile menu#

The header turns solid once the page scrolls. Below 1024px the menu button opens a full-screen menu: page scrolling is locked, focus moves to the first link, Escape closes it and returns focus, and it closes automatically on wide screens.

Headline, reveals and count-up#

RevealController (mounted in the layout) adds is-loaded to <body> so the .line-mask headline lines slide up, and fades in .reveal elements as they scroll into view. When one reveals, everything above it does too, so jumping to an anchor never leaves hidden content. A tiny script in the layout adds js to <html>, so content is only hidden while JavaScript is running. CountUp animates the home page numbers (<CountUp to={140} suffix="+" />). With reduced motion everything shows immediately.

Rotating word#

Rotator (src/components/rotator.tsx) cycles through its words every 2.6 seconds. With reduced motion it shows the first word only.

Marquee#

The services marquee scrolls with the --animate-marquee animation defined in @theme (38 seconds per loop). Its duplicated track is aria-hidden; it stops with reduced motion.

TestimonialCarousel shows the testimonials from src/data/team.ts. It advances every 7 seconds (INTERVAL in the component), pauses on hover, on focus and with the pause button, supports the Left and Right arrow keys, announces changes in a polite live region, and makes hidden slides inert. With reduced motion it starts paused.

Work and careers filters#

FilterChips (src/components/filter-chips.tsx) keeps the chosen filter in the URL hash, so /work#retail and /careers#design can be shared, and reacts if the hash changes. Each chip shows how many items match.

Before / after slider#

CompareSlider on the case study page is a native range input that reveals the "before" image; it works with dragging, arrow keys and screen readers.

Careers roles and application#

RolesBoard lists roles as expandable <details> elements with a team filter. "Apply for this role" sets the role through CareersProvider and scrolls to ApplicationForm, which preselects it.

Local times#

LocalTime shows the current time in an IANA time zone (offices[].tz), updated while the page is open.

Back to top button#

BackToTop (mounted in src/app/layout.tsx) appears after scrolling about one screen, returns to the top (instantly with reduced motion), moves keyboard focus to #main and is hidden when printing. Remove <BackToTop /> from the layout to drop it.

Forms#

Both forms use the useValidatedForm hook from src/components/forms.tsx: fields are checked with the browser's Constraint Validation API on submit and re-checked as they change, errors appear under each field (FieldError, linked with aria-describedby through describe(name)), focus moves to the first problem, and checkbox groups need at least one ticked box. A valid form calls onValid, which shows a SuccessPanel.

Form Component Fields and rules
Project brief ContactForm (src/components/contact-form.tsx) Name, email, company (required); services checkboxes (at least one); budget range (labels in BUDGETS); timeline (required); message (20+ characters); source (optional)
Application ApplicationForm (src/components/careers.tsx) Name, email, role (required); portfolio (required, full https:// URL); location (optional); note (30+ characters); consent (required)

The forms don't send anything yet — onValid only shows the success panel. To deliver submissions with a form service such as Formspree, make onValid post the form data first. In src/components/contact-form.tsx:

tsx
const { errors, form, describe, reset } = useValidatedForm({
  groups: { services: "Please choose at least one service." },
  onValid: async () => {
    const res = await fetch("https://formspree.io/f/your-id", {
      method: "POST",
      body: new FormData(form.ref.current!),
      headers: { Accept: "application/json" },
    });
    if (res.ok) setSent(true);
  },
});

Do the same in ApplicationForm. All fields have name attributes. If you'd rather handle submissions yourself, remove output: "export" from next.config.ts (see below) and post to a Route Handler such as src/app/api/brief/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.formData();
  // validate, then email it or store it
  return Response.json({ ok: true });
}

Saved data (localStorage)#

This template doesn't store anything in the browser.

SEO and sharing#

  • Site-wide defaults are the metadata export in src/app/layout.tsx (with a title template %s — Northbound Studio); pages use pageMetadata(title, description) from src/lib/metadata.ts.

  • metadataBase comes from NEXT_PUBLIC_SITE_URL (default https://northbound.example). Set it to your domain when building so the social image URL is absolute:

    sh
    NEXT_PUBLIC_SITE_URL=https://example.com pnpm build
  • The social image is public/img/og-image.png (1200×630).

  • Add src/app/sitemap.ts and src/app/robots.ts for a sitemap and robots file; both work with static export.

Accessibility#

Built in: a skip link to #main, landmarks, visible focus rings (lime on dark .on-dark sections), keyboard support for the menu, carousel, filters, slider and role details, the ARIA carousel pattern with a live status, linked form errors, and reduced-motion support for every animation. Contrast meets WCAG AA.

When editing, keep one <h1> per page, keep heading levels in order, label new fields and add a FieldError for them, and give images meaningful alt text.

Building and deploying#

sh
pnpm build        # static site in out/
pnpm start        # preview out/

next.config.ts uses output: "export" and images.unoptimized. DEMO_BASE_PATH sets basePath for sub-folder hosting (e.g. DEMO_BASE_PATH=/agency pnpm build) and is exposed as NEXT_PUBLIC_BASE_PATH for asset().

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • Any static host: upload the contents of out/.

For server features (Route Handlers, Server Actions), remove output: "export" and images: { unoptimized: true } and deploy to a host that runs Next.js. demo/, if present, is the freeframe.dev demo build and can be deleted.

TypeScript 7 setup#

@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to @typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it. eslint.config.mjs sets settings.react.version, which ESLint 10 needs.

Troubleshooting#

  • Images 404 when hosted in a sub-folder. Use asset("/img/...") for every /public path and build with DEMO_BASE_PATH.
  • A new case study doesn't appear under its filter. Its industry must be a value from industries.
  • "Apply for this role" doesn't preselect. The role title must match an option in the application form, which is built from roles.
  • A local time is blank. The tz value isn't a valid IANA time zone.
  • Social previews have no image. Build with NEXT_PUBLIC_SITE_URL set to your domain.
  • ESLint crashes with getFilename is not a function. Keep settings.react.version in eslint.config.mjs.
  • next build fails after adding an API route. Remove output: "export".

License#

MIT. The code and all included artwork are free for personal and commercial use; the download includes the license text in LICENSE.txt. The fonts are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation