Freeframe

Documentation

Agency

v1.0.2Updated Oct 9, 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). 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 Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files with no UI framework, and can be hosted anywhere. 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:4321

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/agency into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File What's on it
/ src/pages/index.astro 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 src/pages/services.astro Four services with deliverables, timelines and linked case studies, three engagement models with price ranges, FAQ
/work src/pages/work.astro All six case studies with an industry filter; /work#retail opens it filtered
/case-study src/pages/case-study.astro The Kite Mobility project: facts, challenge, before/after slider, approach, brand system and app screens, results, quote, credits, next project
/about src/pages/about.astro Studio illustration, story timeline, values, eight team portraits
/careers src/pages/careers.astro Perks, seven open roles with a team filter and expandable details, application form
/contact src/pages/contact.astro Project-brief form, email addresses, three studios with live local times
404 src/pages/404.astro Not-found page (most hosts show 404.html automatically)

Project structure#

agency/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    logo, desktop navigation and the full-screen mobile menu
│  │  ├─ SiteFooter.astro    call to action, links, offices with live clocks, copyright
│  │  └─ CaseCard.astro      case study card used on the home and work pages
│  ├─ data/
│  │  ├─ nav.ts              header and footer links, offices, social links
│  │  ├─ cases.ts            case studies and industries
│  │  ├─ services.ts         services, engagement models and the services FAQ
│  │  ├─ team.ts             team members and testimonials
│  │  └─ careers.ts          departments, open roles and perks
│  ├─ lib/
│  │  └─ site.ts             site name, theme colour and the url() helper for links
│  ├─ scripts/
│  │  ├─ main.ts             all interactivity (header, menu, reveals, carousel, filters, forms...)
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (brand tokens, fonts) and shared component classes
├─ public/img/               SVG artwork, favicon.svg, og-image.png (and its og-image.svg source)
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

Pages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent to the browser, plus one inline line in the layout's <head> that adds the js class to <html>. Layout.astro imports both scripts, and Astro bundles them into one small module.

Customising#

Brand and colours#

The brand tokens are the @theme block at the top of src/styles/global.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 colour is site.themeColor in src/lib/site.ts.

Component classes in global.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 rebuilds automatically in pnpm dev and pnpm build, so there's no separate CSS step.

Typography#

Three Google Fonts are loaded by one <link> in src/layouts/Layout.astro: Bricolage Grotesque (headings), Inter (body) and JetBrains Mono (labels). They map to --font-display, --font-sans and --font-mono in @theme. To swap one, change the Google Fonts URL in the layout and the matching token. To avoid Google Fonts, put the font files in public/fonts/, add @font-face rules to global.css and remove the <link> and the two preconnect tags.

Content#

  • Page text lives in each src/pages/*.astro file. The page title and description are the title and description props on <Layout> at the top:

    html
    <Layout title={"Work — Northbound Studio"} description={"Case studies from Northbound Studio..."} current="work">
  • Case studies are cases in src/data/cases.ts. The home page shows the first four and the work page shows all of them, both through src/components/CaseCard.astro. 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 src/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) and serviceFaqs.

  • Team and testimonials are team and testimonials in src/data/team.ts. Each testimonial adds a carousel slide and its dot.

  • Open roles are roles in src/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.

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

  • Headline: each line of the home page headline is a <span class="line-mask"><span> that slides up on load; the --d style sets each line's delay. The rotating word is a <span class="rotator" data-rotator> containing one <span> per word; the first has is-active.

  • Numbers on the home page count up from zero: data-count-to="140" with an optional data-suffix="+" (whole numbers only). Keep the final value as the element's text.

Images and artwork#

All artwork is SVG in public/img/. To use photos, put them in public/img/, keep the proportions and link them with the url() helper so they keep working under a base path:

html
<img src={url("/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
Social image (og-image.png) 1200×630

Image paths in src/data/*.ts are written as "/img/..."; the components pass them through url(). The before/after slider on /case-study uses kite-before.svg and case-kite.svg; both must have the same aspect ratio (16:10). The favicon is public/img/favicon.svg. 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 src/data/nav.ts. Each item has href, label and key; a link gets aria-current="page" when the page's current prop equals its key (the case study passes current="work").
  • src/components/SiteHeader.astro is the header and full-screen mobile menu (the menu numbers its links 01, 02..., hidden from screen readers). src/components/SiteFooter.astro is the dark footer; it also renders offices (with live clocks) and socials from nav.ts.

To add a page, create src/pages/journal.astro:

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Journal — Northbound Studio" description="Notes from the studio." current="journal">
  <main id="main">
    <section class="container-x pt-36 pb-16">
      <h1 class="display-xl">Journal</h1>
    </section>
  </main>
</Layout>

Then add { href: "/journal", label: "Journal", key: "journal" } to nav. Always write internal links as href={url("/journal")} (from src/lib/site.ts) so they keep working under a base path. Each page supplies its own <main id="main">, which the skip link targets.

Features#

All interactive code is in src/scripts/main.ts, one commented block per feature. Each block looks for its elements by class or data-* attribute and does nothing if they're missing.

Header and mobile menu#

.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.

Headline and rotating word#

On load, the script adds is-loaded to <body>, which slides each .line-mask line into place. The rotating word cycles 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 duplicated for a seamless loop and marked aria-hidden="true"; the real list of services is elsewhere on the page. It stops with reduced motion.

Scroll reveal and count-up#

.reveal elements fade in as they scroll into view. 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 the layout), so content stays visible without JavaScript. Counters ([data-count-to]) count up over 1.6 seconds. With reduced motion everything shows immediately.

The [data-carousel] region on the home page has slides ([data-slide]), dots ([data-dot]), previous / next buttons ([data-prev], [data-next]) and a pause/play button ([data-carousel-toggle]).

  • 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.
  • With reduced motion it starts paused.

Slides and dots are generated from testimonials in src/data/team.ts.

Filters (work and careers)#

A filter is a group with data-filter-group="<key>" containing buttons with data-filter="<value>" (and data-filter="all"). Items anywhere on the page with data-<key>="<value>" are shown or hidden. Each button's .count shows how many items match, [data-filter-count="<key>"] shows the result count, and [data-filter-empty="<key>"] appears when nothing matches. The chosen filter is written to the URL hash, so /work#fintech or /careers#engineering can be shared. The buttons and items are rendered from industries / cases and departments / roles, so they always match.

Before / after slider#

On /case-study, [data-compare] contains two images and a native range input (#compare-range). Dragging or using the arrow keys sets the --pos custom property that reveals the "before" 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 and preselects the role in #apply-role.

Contact budget slider#

#budget is a range input with five steps; the script shows the matching label in [data-budget-out] and sets aria-valuetext. Change the labels in the steps array in the "Contact: budget slider label" block of src/scripts/main.ts:

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

Keep the input's max (in src/pages/contact.astro) equal to the number of steps minus one.

Office clocks#

Any element with data-tz="Europe/Lisbon" (an IANA time zone) shows that city's current time, updated every 30 seconds. The footer's clocks come from offices in src/data/nav.ts.

Back to top button#

src/scripts/back-to-top.ts adds a .back-to-top button that 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 global.css. To remove it, delete the file, its import in Layout.astro and those styles.

Forms#

Forms with data-validate are checked in the browser. Error messages are created next to each field (inside its [data-field] wrapper), the field gets aria-invalid and aria-describedby, and focus moves to the first problem. Checkbox groups marked data-required-group="message" need at least one ticked box. A valid form hides itself and shows the [data-success] panel next to it; [data-reset-form] brings the form back.

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:

  1. Give the form an action in src/pages/contact.astro (or careers.astro): <form class="grid gap-10" novalidate data-validate action="https://formspree.io/f/your-id" method="POST">.

  2. In src/scripts/main.ts, find the comment // Demo only: nothing is sent. in the form submit handler and replace the lines after it with:

    ts
    const success = $("[data-success]", form.parentNode as ParentNode);
    fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        form.hidden = true;
        if (success) {
          success.hidden = false;
          success.focus();
        }
      })
      .catch(() => alert("Sorry, something went wrong. Please email us instead."));

All fields have name attributes, and the services checkboxes share name="services", so the service receives every ticked value. To handle submissions with your own code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/contact.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

This template doesn't store anything in the browser.

SEO and sharing#

  • Each page passes title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://agency.example.com). Set it to your domain when you build:

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

    or change the default in astro.config.mjs. 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 @astrojs/sitemap (pnpm astro add sitemap); it uses the same site. 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 build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as work.html rather than work/index.html (build.format: "file" in astro.config.mjs), and links point to /work without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /work from work.html automatically; Vercel does with cleanUrls (below).

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /work serves work.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /work to work.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/agency pnpm build. It becomes Astro's base, and every link written with url() includes it.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

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 src/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.
  • Words run together after editing. Astro removes line breaks between text and a following inline element. Keep the text and the element on one line, or add {" "} (see "Trusted partner to" in src/pages/index.astro).
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() rather than plain "/..." strings.
  • /work shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Link previews show the wrong domain or no image. Set SITE_URL to your domain at build time.
  • pnpm install skips a build script. pnpm-workspace.yaml lists the packages allowed to run install scripts (esbuild, sharp, @tailwindcss/oxide); add any new one there.

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