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.
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.jsonComponents, 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:
@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/*.vuefile. The page title and description are passed tousePageSeo()at the top:tsusePageSeo({ title: "Work — Northbound Studio", description: "Case studies from Northbound Studio: brand identities, apps, websites and campaigns...", }); -
Case studies are
casesinapp/data/cases.ts. The home page shows the first four and the work page shows all of them, both throughCaseCard.vue.industrymust be one of theindustriesvalues (mobility,fintech,food,health,media,retail); the work page builds its filter buttons fromindustries. 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
Industrytype and toindustries; the counts update automatically. -
Services are
servicesinapp/data/services.ts(id, title, summary, intro, deliverables, timeline, example project). The home page list and the four sections of/servicesare built from them, andidis the section's anchor (/services#brand). The same file hasengagementModels(the three price cards;featured: truegives the dark "Most popular" card),serviceFaqs, and the home page'smarquee,process,statsandawards. -
Team and testimonials are
teamandtestimonialsinapp/data/team.ts, with the about page'sstoryandvalues. Each testimonial adds a carousel slide and its dot. -
Open roles are
rolesinapp/data/careers.ts.departmentmust be one ofdepartments(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.perksfills 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--dstyle sets each line's delay. The rotating words are thewordspassed to<WordRotator>inapp/pages/index.vue; keep the screen-reader sentence next to it in step. -
Numbers on the home page are
stats(toand an optionalsuffix, 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:
<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.
Navigation, header and footer#
- Header, mobile menu and footer links come from
NAVinapp/data/site.ts. Each item hashrefandlabel;alsolists other pages that mark it as current (the case study marks Work).useCurrentPage()gives a linkaria-current="page"when it points at the page being shown. SiteHeader.vueis the header and full-screen mobile menu (the menu numbers its links 01, 02..., hidden from screen readers).SiteFooter.vueis the dark footer; it also rendersOFFICES(with live clocks) andSOCIALSfromsite.ts.
To add a page, create app/pages/journal.vue:
<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.
Testimonial carousel#
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-hiddenandinert; the current dot hasaria-current="true". - With reduced motion it starts paused. Change the interval with
INTERVALat 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:
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.
Copyright year#
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:
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:typeand asummary_large_imageTwitter card, plus a canonical link. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromSITE_URL(runtimeConfiginnuxt.config.ts, defaulthttps://northbound.example) plus the base path. Set it to your domain when you build:shSITE_URL=https://example.com pnpm generateCanonical 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 writepublic/sitemap.xmlby hand, and add apublic/robots.txtthat 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 hasaria-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#
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 avercel.jsonwith{ "cleanUrls": true }so/workserveswork.html. - GitHub Pages: build with
pnpm generatein a workflow and publish.output/public. For a project site (username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
.output/public/. If the host doesn't map/worktowork.html, setautoSubfolderIndextotrueinnuxt.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-filterexactly. When you add data, use one of theindustries/departmentsvalues inapp/data/. - "Apply for this role" doesn't preselect the role. The
data-applytext must match an<option>in#apply-role; both come fromroles, so a role added by hand to the page needs its option too. - An office clock shows
--:--. Thetzvalue inOFFICESisn't a valid IANA time zone name. - A new page shows the 404 page after
pnpm generate. Add its path toroutesinnuxt.config.ts. - Words run together or gain a space after editing. Whitespace between tags is kept as written
(
whitespace: "preserve"innuxt.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
.revealand a:classbinding; move the binding to an inner element. - Link previews show the wrong domain or no image. Set
SITE_URLto your domain at build time. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) once sonuxt preparewrites 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.