Freeframe

Documentation

Agency

v1.0.1Updated Nov 11, 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 (Birmingham, AL, Oakland and New York). It has 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 Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS v4 version, version 1.0.1. Every page is generated to static HTML, so it can be hosted anywhere. See it live at freeframe.dev/templates/agency.

Quick start#

Requirements: Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000 with hot reload
pnpm generate Static site in .output/public/ (every page prerendered)
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm preview Serve the last build locally
pnpm typecheck nuxt prepare, then type-check the app with vue-tsc
pnpm lint ESLint (Vue, TypeScript and Nuxt rules)
pnpm build:demo Build for /demos/nuxt/agency into demo/ (only used for the demo on freeframe.dev)

pnpm install also runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.

Pages#

Every file in app/pages/ becomes one page.

URL File What's on it
/ app/pages/index.vue Line-by-line headline with a rotating word, featured project, services marquee, client logos, services list, selected work, process, count-up numbers and awards, testimonial carousel
/services app/pages/services.vue Four services with deliverables, timelines and linked case studies, three engagement models with price ranges, FAQ
/work app/pages/work.vue All six case studies with an industry filter; /work#retail opens it filtered
/case-study app/pages/case-study.vue The Kite Mobility project: facts, challenge, before/after slider, approach, brand system and app screens, results, quote, credits, next project
/about app/pages/about.vue Studio illustration, story timeline, values, eight team portraits
/careers app/pages/careers.vue Perks, seven open roles with a team filter and expandable details, application form
/contact app/pages/contact.vue Project-brief form, email addresses, three studios with live local times
404 app/error.vue Not-found page, generated as 404.html (most hosts show it automatically)

Project structure#

agency/
├─ app/
│  ├─ app.vue                     <NuxtLayout><NuxtPage /></NuxtLayout>
│  ├─ error.vue                   the 404 page
│  ├─ router.options.ts           keeps query strings written the way the links write them
│  ├─ layouts/default.vue         skip link, header, the page, footer, back-to-top button
│  ├─ pages/                      one .vue file per page (see above)
│  ├─ components/
│  │  ├─ SiteHeader.vue           logo, desktop navigation and the full-screen mobile menu
│  │  ├─ SiteFooter.vue           call to action, links, offices with live clocks, copyright year
│  │  ├─ CaseCard.vue             case study card used on the home and work pages
│  │  ├─ WordRotator.vue          the rotating word in the home headline
│  │  ├─ CountUp.vue              a number that counts up when it scrolls into view
│  │  ├─ TestimonialCarousel.vue  the home page carousel
│  │  ├─ CompareSlider.vue        the case study's before/after slider
│  │  ├─ LocalTime.vue            the current time in one studio's time zone
│  │  ├─ FieldError.vue           an inline form error
│  │  ├─ BackToTop.vue            the floating "Back to top" button
│  │  └─ ArrowIcon.vue, CornerArrow.vue   the two arrow icons
│  ├─ composables/
│  │  ├─ usePageSeo.ts            title, description, canonical and Open Graph tags
│  │  ├─ useCurrentPage.ts        aria-current for header and menu links
│  │  ├─ useHashFilter.ts         the work and careers filters, kept in the #hash
│  │  ├─ useFormValidation.ts     inline validation for both forms
│  │  ├─ useYear.ts, useAsset.ts  the copyright year; public/ paths with the base path
│  ├─ data/
│  │  ├─ site.ts                  studio name and emails, menu, offices, social links
│  │  ├─ cases.ts                 case studies and industries
│  │  ├─ services.ts              services, engagement models, FAQ, marquee, process, numbers, awards
│  │  ├─ team.ts                  team, testimonials, story timeline, values
│  │  ├─ careers.ts               departments, open roles and perks
│  │  └─ contact.ts               the brief's service tiles, budget steps, timelines and sources
│  ├─ plugins/
│  │  ├─ reveal.client.ts         headline slide-in and scroll reveals on every page
│  │  ├─ keep-hash.client.ts      keeps the #hash exactly as opened, for the filters
│  │  └─ stable-build.server.ts   keeps generated files identical between builds
│  ├─ utils/                      makeRef(), replaceHash(), prefersReducedMotion() (auto-imported)
│  └─ assets/
│     ├─ css/main.css             fonts, Tailwind theme (brand tokens) and shared component classes
│     └─ fonts/                   Bricolage Grotesque, Inter and JetBrains Mono (woff2 + OFL texts)
├─ public/img/                    SVG artwork, favicon.svg, og-image.png (and its og-image.svg source)
├─ nuxt.config.ts                 base path, <head> defaults, SITE_URL, prerendered routes, Tailwind
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

Components, composables and utils are auto-imported by Nuxt; data files are imported by path (import { cases } from "~/data/cases").

Customizing#

Brand and colors#

The brand tokens are the @theme block in app/assets/css/main.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 */
}

Each token becomes Tailwind utilities (bg-paper, text-ink, text-muted, border-line, text-cobalt, bg-lime...). To rebrand, change --color-cobalt (and its darker -600 hover shade) and --color-lime. Keep text on paper and ink at WCAG AA contrast. The browser theme color is the theme-color meta in nuxt.config.ts.

Component classes in main.css: .btn with .btn-primary, .btn-outline, .btn-lime, .btn-ghost-dark; .chip (filter buttons); .eyebrow (small monospace labels); .display-xl and .display-lg (big headings); .field, .label, .field-error, .check-tile (forms); .case-card, .link-u (underlined links), .marquee, .rotator, .compare, .slide, .dot, .role. Sections with a dark background use the on-dark class, which switches focus rings to lime. Tailwind is compiled by Vite in pnpm dev and every build, so there's no separate CSS step.

Typography#

Three fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css: Bricolage Grotesque (headings, variable weight 400–800), Inter (body, 400–700) and JetBrains Mono (labels). They map to --font-display, --font-sans and --font-mono in @theme. These are the same Latin files Google Fonts serves, so text sets exactly as designed. To swap one, put its woff2 file in app/assets/fonts/, change the @font-face rule and the matching token.

Content#

  • Page text lives in each app/pages/*.vue file. The page title and description are passed to usePageSeo() at the top:

    ts
    usePageSeo({
      title: "Work — Northbound Studio",
      description: "Case studies from Northbound Studio: brand identities, apps, websites and campaigns...",
    });
  • Case studies are cases in app/data/cases.ts. The home page shows the first four and the work page shows all of them, both through CaseCard.vue. industry must be one of the industries values (mobility, fintech, food, health, media, retail); the work page builds its filter buttons from industries. Add a case:

    ts
    { client: "Your Brand", year: 2026, title: "What you made, in one line", industry: "retail", services: "Brand, web", image: "img/case-yourbrand.jpg", href: "/work#retail" },

    For a new industry, add it to the Industry type and to industries; the counts update automatically.

  • Services are services in app/data/services.ts (id, title, summary, intro, deliverables, timeline, example project). The home page list and the four sections of /services are built from them, and id is the section's anchor (/services#brand). The same file has engagementModels (the three price cards; featured: true gives the dark "Most popular" card), serviceFaqs, and the home page's marquee, process, stats and awards.

  • Team and testimonials are team and testimonials in app/data/team.ts, with the about page's story and values. Each testimonial adds a carousel slide and its dot.

  • Open roles are roles in app/data/careers.ts. department must be one of departments (design, engineering, strategy, operations). Each role's title also becomes an option in the application form's role select, so "Apply for this role" can preselect it. perks fills the "Why people stay" list.

  • The project brief's options (service tiles, budget steps, timelines, "how did you hear about us") are in app/data/contact.ts.

  • Navigation, offices and social links are in app/data/site.ts (see below).

  • Headline: each line of a page headline is a <span class="line-mask"><span> that slides up on load; the --d style sets each line's delay. The rotating words are the words passed to <WordRotator> in app/pages/index.vue; keep the screen-reader sentence next to it in step.

  • Numbers on the home page are stats (to and an optional suffix, whole numbers only).

Images and artwork#

All artwork is SVG in public/img/. To use photos, put them in public/img/ and keep the proportions. Paths in app/data/*.ts are written without a leading slash ("img/case-kite.svg"); components pass them through useAsset(), which adds the base path:

html
<img :src="asset('img/case-yourbrand.jpg')" alt="Your project artwork" width="1600" height="1000" loading="lazy">
Image Size / ratio
Case study covers (case-*.svg) 1600×1000 (16:10)
Team portraits (team-*.svg) 400×480 (5:6)
Case study images (kite-*.svg) 16:10
Studio illustration (studio.svg) 1600×900 (16:9)
Social image (og-image.png) 1200×630

The before/after slider uses kite-before.svg and case-kite.svg; both must have the same aspect ratio (16:10). The favicon is public/img/favicon.svg (also the logo mark in the header and footer). The social image is public/img/og-image.png, exported from public/img/og-image.svg.

  • Header, mobile menu and footer links come from NAV in app/data/site.ts. Each item has href and label; also lists other pages that mark it as current (the case study marks Work). useCurrentPage() gives a link aria-current="page" when it points at the page being shown.
  • SiteHeader.vue is the header and full-screen mobile menu (the menu numbers its links 01, 02..., hidden from screen readers). SiteFooter.vue is the dark footer; it also renders OFFICES (with live clocks) and SOCIALS from site.ts.

To add a page, create app/pages/journal.vue:

html
<script setup lang="ts">
usePageSeo({ title: "Journal — Northbound Studio", description: "Notes from the studio." });
</script>

<template>
  <main id="main">
    <section class="container-x pt-36 pb-16">
      <h1 class="display-xl">Journal</h1>
    </section>
  </main>
</template>

Then add { href: "/journal", label: "Journal" } to NAV and "/journal" to routes in nuxt.config.ts (every page is listed there so it is generated). Write internal links as <NuxtLink to="/journal">: NuxtLink adds the base path. NuxtLink marks a link to the current page with aria-current on its own, so links that shouldn't be marked (the logo, footer links) pass :aria-current="undefined". Each page supplies its own <main id="main">, which the skip link targets.

Features#

Each feature is a component or composable, and the effects that work on any page (headline, scroll reveals) are in app/plugins/reveal.client.ts. Anything that depends on the clock, the address or the browser runs after the page has loaded, so the generated HTML shows the text as written.

Header and mobile menu#

SiteHeader.vue: .site-header turns solid once the page scrolls more than 12px. Below 1024px the menu button ([data-menu-button]) opens a full-screen #mobile-menu: focus moves to its first link, page scrolling is locked, Escape closes it and returns focus to the button, and it closes when the window grows past 1024px or another page opens.

Headline and rotating word#

On load (and after each page change), the reveal plugin adds is-loaded to <body>, which slides each .line-mask line into place. WordRotator.vue cycles the words every 2.6 seconds. With reduced motion, the headline appears immediately and the word doesn't rotate (the first word stays).

Marquee and logo wall#

The services marquee scrolls continuously with the --animate-marquee animation (38 seconds per loop) defined in @theme. Its track is rendered twice for a seamless loop and marked aria-hidden="true"; a screen-reader sentence lists the services. It stops with reduced motion and pauses on hover.

Scroll reveal and count-up#

.reveal elements fade in as they scroll into view (reveal.client.ts). When one reveals, everything above it reveals too, so jumping to an anchor never leaves hidden content. The hiding styles only apply when <html> has the js class (added by the inline script in nuxt.config.ts), so content stays visible without JavaScript. Don't put .reveal on an element that also has a :class binding: Vue would drop the is-in class the next time it updates. CountUp.vue counts from zero over 1.6 seconds the first time a number scrolls into view. With reduced motion everything shows immediately.

TestimonialCarousel.vue (a [data-carousel] region) has slides, dots, previous/next buttons and a pause/play button.

  • It advances every 7 seconds and pauses while the pointer is over it or focus is inside it.
  • Left and Right arrow keys move between slides; a live region ([data-carousel-status]) announces "Testimonial 2 of 3" when you change slides yourself.
  • Hidden slides are aria-hidden and inert; the current dot has aria-current="true".
  • With reduced motion it starts paused. Change the interval with INTERVAL at the top of the component.

Filters (work and careers)#

useHashFilter() in app/composables/useHashFilter.ts holds the chosen value. The buttons (data-filter="<value>", with aria-pressed) and the items (data-industry / data-department) are rendered from industries / cases and departments / roles, so they always match. Each button's .count shows how many items match, [data-filter-count] shows the result count (a polite live region) and [data-filter-empty] appears when nothing matches. The chosen filter is written to the address as a hash, so /work#fintech or /careers#engineering can be shared; an unknown hash shows everything.

Before / after slider#

CompareSlider.vue on /case-study stacks the two images and a native range input (#compare-range) over the whole picture. Dragging, clicking or using the arrow keys sets the --pos custom property, which clips the "after" image. Because it's a real <input type="range">, it works with keyboards and screen readers.

Careers roles#

Each role is a native <details> element, so it expands with a click, Enter or Space. "Apply for this role" (data-apply) jumps to the form (#apply) and preselects the role in #apply-role.

Contact budget slider#

#budget is a range input with five steps; the page shows the matching label in [data-budget-out] and sets aria-valuetext. Change the labels in budgetSteps in app/data/contact.ts:

ts
export const budgetSteps = ["Under $25k", "$25k – $50k", "$50k – $100k", "$100k – $250k", "$250k+"];

Keep the input's max (in app/pages/contact.vue) equal to the number of steps minus one, and its value equal to budgetStart.

Office clocks#

LocalTime.vue shows a city's current time (tz, an IANA time zone such as America/Los_Angeles), updated every 30 seconds. The generated HTML shows --:-- until the page has loaded. The footer and contact page clocks come from OFFICES in app/data/site.ts.

The footer's year (useYear()) is written as 2026 in the generated HTML and set to the current year in the browser, so the demo never shows an old year.

Back to top button#

BackToTop.vue, rendered at the end of <body> by the layout, appears after you scroll down about one screen. It scrolls back up (instantly with reduced motion) and moves keyboard focus to #main. It hides while the footer's own "Back to top" link is on screen and lifts itself above anything marked data-back-to-top-avoid; mark pinned UI with data-back-to-top-hide to hide it while covering it. The styles are the .back-to-top rules in main.css. To remove it, delete the component, its tag in app/layouts/default.vue and those styles.

Forms#

Both forms use useFormValidation() (app/composables/useFormValidation.ts), which relies on the browser's own checks (required, type="email", type="url", minlength) with friendlier messages. A field is checked when you leave it and again as you fix it; an error appears as a <FieldError> inside the field's [data-field] wrapper, and the field gets aria-invalid and aria-describedby. The checkbox group marked data-required-group="<message>" needs at least one checked box. Sending moves focus to the first problem. A valid form hides itself and shows the [data-success] panel next to it; "Send another" brings the form back empty. The panel shows a short reference (NS- and four characters, made in the browser by makeRef(); use your form service's own reference instead if it returns one). The panels say what happens next, using the reply times the pages already promise (two business days for inquiries, ten for applications): change both if yours differ.

Form Page Fields and rules
Project brief /contact Name, email, company (all required); services checkboxes (at least one, #services-group); budget range; timeline (required); message (required, 20+ characters); "how did you hear about us" (optional)
Application /careers Name, email, role (required); portfolio link (required, must be a full https:// URL); location (optional); note (required, 30+ characters); consent checkbox (required)

The forms don't send anything yet. To receive submissions with a service such as Formspree, replace the callback passed to useFormValidation() in app/pages/contact.vue (or careers.vue) with one that posts the form:

ts
const v = useFormValidation(async () => {
  const form = formEl.value!;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return alert("Sorry, something went wrong. Please email us instead.");
  reference.value = makeRef();
  sent.value = true;
  await nextTick();
  success.value?.focus();
});

All fields have name attributes, and the services checkboxes share name="services", so the service receives every checked value. To handle submissions with your own code instead, add a server route such as server/api/contact.post.ts, post to /api/contact, and deploy with pnpm build to a host that runs Node.js (the pages stay prerendered).

Saved data (localStorage)#

This template doesn't store anything in the browser.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets <title>, the meta description, og:title, og:description, og:site_name, og:type and a summary_large_image Twitter card, plus a canonical link.

  • The canonical URL, og:url and the absolute og:image URL are built from SITE_URL (runtimeConfig in nuxt.config.ts, default https://northbound.example) plus the base path. Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm generate

    Canonical URLs have no .html (https://example.com/work).

  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • For a sitemap, add a module such as @nuxtjs/sitemap, or write public/sitemap.xml by hand, and add a public/robots.txt that points to it.

Accessibility#

Built in:

  • A skip link (.skip-link) to #main, landmarks, and visible focus rings (cobalt on paper, lime on dark sections).
  • Keyboard support for the menu, carousel (arrow keys, pause button), filters (aria-pressed), before/after slider (native range input) and roles (native <details>).
  • The carousel follows the ARIA carousel pattern with a live status message and inert hidden slides.
  • Form errors are linked to their fields with aria-describedby; the budget slider has aria-valuetext.
  • Reduced motion: no headline animation, rotating word, marquee, reveals, count-ups or auto-advancing carousel.
  • WCAG AA contrast throughout.

When editing, keep one <h1> per page, keep heading levels in order, label every new field, and give every image meaningful alt text (or alt="" if purely decorative).

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # preview it locally

.output/public/ is plain HTML, CSS and JavaScript. Pages are written as work.html rather than work/index.html (autoSubfolderIndex: false in nuxt.config.ts), and links point to /work without a trailing slash. Each page also has a small _payload.json that Nuxt fetches when you move between pages. Netlify, Cloudflare Pages and GitHub Pages serve /work from work.html automatically; Vercel does with cleanUrls (below).

  • Netlify / Cloudflare Pages: build command pnpm generate, publish directory .output/public.
  • Vercel: import the repository; it detects Nuxt. For a static deployment set the build command to pnpm generate, and add a vercel.json with { "cleanUrls": true } so /work serves work.html.
  • GitHub Pages: build with pnpm generate in a workflow and publish .output/public. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of .output/public/. If the host doesn't map /work to work.html, set autoSubfolderIndex to true in nuxt.config.ts.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/agency pnpm generate. It becomes Nuxt's app.baseURL; NuxtLink and useAsset() add it to every link and image.

nuxt generate also leaves a dist link to .output/public; it is ignored by git. demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

Troubleshooting#

  • A filter shows no items. Item values must match a button's data-filter exactly. When you add data, use one of the industries / departments values in app/data/.
  • "Apply for this role" doesn't preselect the role. The data-apply text must match an <option> in #apply-role; both come from roles, so a role added by hand to the page needs its option too.
  • An office clock shows --:--. The tz value in OFFICES isn't a valid IANA time zone name.
  • A new page shows the 404 page after pnpm generate. Add its path to routes in nuxt.config.ts.
  • Words run together or gain a space after editing. Whitespace between tags is kept as written (whitespace: "preserve" in nuxt.config.ts); keep text and the following element on one line where the design has no space.
  • A revealed section disappears again after a click. The element has both .reveal and a :class binding; move the binding to an inner element.
  • Link previews show the wrong domain or no image. Set SITE_URL to your domain at build time.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) once so nuxt prepare writes it.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. The fonts are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation