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).
pnpm install
pnpm dev # http://localhost:4321With 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.jsonPages 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:
@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/*.astrofile. The page title and description are thetitleanddescriptionprops on<Layout>at the top:html<Layout title={"Work — Northbound Studio"} description={"Case studies from Northbound Studio..."} current="work"> -
Case studies are
casesinsrc/data/cases.ts. The home page shows the first four and the work page shows all of them, both throughsrc/components/CaseCard.astro.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
servicesinsrc/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) andserviceFaqs. -
Team and testimonials are
teamandtestimonialsinsrc/data/team.ts. Each testimonial adds a carousel slide and its dot. -
Open roles are
rolesinsrc/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. -
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--dstyle sets each line's delay. The rotating word is a<span class="rotator" data-rotator>containing one<span>per word; the first hasis-active. -
Numbers on the home page count up from zero:
data-count-to="140"with an optionaldata-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:
<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.
Navigation, header and footer#
- Header, mobile menu and footer links come from
navinsrc/data/nav.ts. Each item hashref,labelandkey; a link getsaria-current="page"when the page'scurrentprop equals itskey(the case study passescurrent="work"). src/components/SiteHeader.astrois the header and full-screen mobile menu (the menu numbers its links 01, 02..., hidden from screen readers).src/components/SiteFooter.astrois the dark footer; it also rendersoffices(with live clocks) andsocialsfromnav.ts.
To add a page, create src/pages/journal.astro:
---
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.
Testimonial carousel#
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-hiddenandinert; the current dot hasaria-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:
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:
-
Give the form an action in
src/pages/contact.astro(orcareers.astro):<form class="grid gap-10" novalidate data-validate action="https://formspree.io/f/your-id" method="POST">. -
In
src/scripts/main.ts, find the comment// Demo only: nothing is sent.in the formsubmithandler and replace the lines after it with:tsconst 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
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://agency.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildor 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 samesite. 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 build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/workserveswork.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/worktowork.html, changebuild.formatto"directory"inastro.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-filterexactly. When you add data, use one of theindustries/departmentsvalues insrc/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. - 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" insrc/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 withurl()rather than plain"/..."strings. /workshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Link previews show the wrong domain or no image. Set
SITE_URLto your domain at build time. pnpm installskips a build script.pnpm-workspace.yamllists 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.