045

Web Design Agency Template

£10

A one-page site for a web design and development studio, built around a browser mockup that scroll-scrubs through the three stages of a real project: wireframe, then structure, then the finished thing. Services lead with plain price bands and timelines, and the whole page is keyed to one brand colour you swap.

ScrollTriggerSplitTextDrawSVG Lenis intermediate
13 more details
Flip pinningscrubmask-revealtext-animationsvg-drawflip-layoutfiltercount-upmagneticscroll-revealstaggerprogressive-enhancement
Web Design Agency Template - website template preview

This template reads better at your own screen size than in the frame below:

About this template

A production-ready one-page site for a studio that designs and builds websites for other people. It ships as a finished site for a fictional six-person studio, Northbound, with real engagement types, plain price bands, honest timelines and a week-by-week process already written, so what you open reads as a working business rather than a grid of placeholder headings.

Read the full template overview

It is built to be re-pointed. The whole page is keyed to a single brand colour: set two values, your accent and the type colour that sits on it, and every button, chip, rule, node and progress fill follows. The accent is never used as a text colour anywhere, which is what makes that swap safe for any hue rather than only for the blue it ships with. The rest of the system is a warm greige ground, a lighter card surface and one dark band, all resolved from RGB triples so there is not a single colour literal to hunt down.

The centrepiece is a browser mockup that a pinned, scrubbed timeline moves through three fidelities as you scroll: grey boxes first, then real type at real sizes, then colour and imagery last. It is the studio's own argument about how a project should run, made by showing it rather than claiming it. Around that sit four engagement types with scope lists and price bands, six project cards drawn in CSS with documented image swap points, a process rail that draws week by week, and an enquiry form that asks what a studio actually needs to know.

There is no bundled photography and none is needed: the project cards are composed mockups, so a buyer drops in their own screenshots rather than fighting a graded photo set. Every section is found by a data attribute and separately guarded, so delete the hero, the build sequence, the services, the work grid, the process or the enquiry block and the rest keeps running.

What's included

10 items
  • A pinned browser mockup that scroll-scrubs through wireframe, structure and finished states, with the wireframe bars and the real type occupying the same grid cell so no line ever changes height between stages
  • Keyed to one brand colour: set an accent and its paired on-accent type colour, and buttons, chips, rules, nodes, focus rings and the progress fill all follow, with the accent barred from carrying text so any hue stays legible
  • Four engagement types with plain scope lists, indicative price bands and honest timelines, which is the commercial heart of the page and the part most studios have to write from scratch
  • Six project cards drawn entirely in CSS, each a browser-framed mockup with a documented image swap point and a designed plate behind it, so an unloaded screenshot leaves the page looking art-directed rather than broken
  • A week-by-week process rail with a DrawSVG line that draws as it enters and lit nodes marking each stage
  • A work filter built on radio inputs and CSS, so it still filters with JavaScript disabled, with Flip animating the reflow and a min-height lock so nothing below the grid lurches mid-tween
  • Count-up studio figures with accent tick marks, and a magnetic primary call to action on fine pointers only
  • A hero whose vertical rhythm answers to viewport height as well as width, so the primary call to action stays above the fold on short laptop screens instead of falling below it
  • Every measured contrast ratio written into the stylesheet header, including why the relaxed large-text exemption is deliberately not used anywhere
  • Accessible throughout: a complete static document with no JavaScript, a full page under reduced motion with the mockup showing its finished state and nothing pinned, and a blocked CDN that degrades to the plain document

Perfect for

6 use cases
  • Web design and development studios that need a site for their own business
  • Freelance designers and developers presenting services, process and rates on one page
  • Small digital and branding agencies that sell scoped projects rather than retainers alone
  • Any studio that wants to lead with what it sells and how it works, rather than with a portfolio wall
  • Consultancies that need plain price bands and timelines on the page before the first call
  • A starting point for any service business, since the palette is one brand colour and the sections are independent

How it works

5 sections

Structure

One script initialises every section with a single setup and teardown path, and one motion branch whose conditions cover coarse pointers as well as desktop, so exactly one branch runs on every device. Each block is found by a data attribute and returns early if it is absent, so deleting any section leaves the others working. The page is a complete static document before any script runs.

The three fidelities

One mockup, one layout, three sets of hooks. Each slot is a grid cell holding both a grey wireframe bar and the real type in the same cell, so a line never changes height as the stages swap. The scrubbed timeline holds on wireframe, crosses the bars out and the type in, holds on structure, then brings colour and imagery up last. The finished state is the default in CSS and the JavaScript gating class winds it back to the wireframe, which is why it renders finished with no JavaScript and under reduced motion.

One colour to change

The accent and the type colour that sits on it are separate tokens, because which one you need depends on your hue: a mid blue, red, green, purple, teal or pink wants white on it, while orange, yellow and lime want near-black. The accent never colours text anywhere on the page, only fills and non-text marks, so a swap cannot leave a figure or a label unreadable. Every translucent value resolves from an RGB triple, so a reskin is the token block and nothing else.

The work filter

The filter is radio inputs and CSS selectors rather than JavaScript buttons, so it still works with scripting disabled and the control bar is never a row of dead chips. Flip only animates the reflow on top of that. The grid's height is locked for the duration of the tween and released on completion, because a Flip reflow lifts its items out of normal flow and would otherwise drop the container height and jolt everything below it.

Without JavaScript

The gating class goes on in the head before first paint, so reveal start states apply to the first frame and nothing is painted visible and then hidden; the script removes that class when GSAP is missing, so a blocked CDN degrades to the plain document. With scripting off the mockup shows its finished state, the filter still filters, the process rail is fully drawn, and every figure shows its final value.

Plugins ScrollTrigger, SplitText, DrawSVG, Flip
Difficulty Intermediate
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo, 9 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Questions about this template

What does the browser mockup actually show?

It scroll-scrubs one project through three fidelities: wireframe, then structure, then the finished thing. The wireframe bars and the real type occupy the same grid cell, so no line changes height between stages and the transition reads as the same page resolving rather than three different pages.

Do I have to publish prices?

The template assumes you will, and that is its commercial argument. Four engagement types ship with plain scope lists, indicative price bands and honest timelines. It is the part most studios have to write from scratch, and the part that qualifies leads before the first call. You can strip the bands, but you lose the reason to pick this one.

Do I need screenshots of real client work?

Not to launch. Six project cards are drawn entirely in CSS as browser-framed mockups, each with a documented image swap point and a designed plate behind it, so an unloaded screenshot leaves the page looking art-directed. Swap them for real work as you win it.

How much work is rebranding it?

One accent colour and its paired on-accent type colour. Buttons, chips, rules, nodes, focus rings and the progress fill all follow, and the accent is barred from carrying text so any hue you pick stays legible. Every measured contrast ratio is written into the stylesheet header.

Does the work filter need JavaScript?

No. It is built on radio inputs and CSS :has(), so it still filters with scripting disabled. When JavaScript is available, Flip animates the reflow and a min-height lock stops the content below lurching mid-tween.

Does the enquiry form actually send?

No. It validates in the browser and shows a success state, but its action is a placeholder, so nothing arrives. Add a handler before launch. Since the form asks for a budget band and a project type, post it straight into wherever you triage new business rather than to an inbox nobody owns.

Paid template

Purchase to unlock the code.

Buying Web Design Agency Template opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£10 Standard license, unlimited projects

Browse free effects

Documentation

Quick Start

1. Copy the folder and open index.html. That is the whole site.

2. Delete the robots meta. Line 6 of index.html carries <meta name="robots" content="noindex, nofollow"> so the demo does not get indexed. Remove it or your site will not be indexed either. While you are in <head>, set the <title> and the <meta name="description">.

3. Set your brand colour. Four tokens at the top of assets/style.css. This is the change that makes the page yours more than any other, so read Brand colour before you touch it.

4. Replace the copy. All of it is plain markup in index.html, in the order it appears on the page: masthead, hero, build sequence, services, work, process, contact, footer. Each section is fenced by a comment block naming it.

5. Drop in your project screenshots. Six commented swap points in the Work section, one per card, with the CSS already written. See The work section and images.

6. Wire the form up. It is a normal HTML <form action="#" method="post">. Change the action to your handler (Formspree, Basin, Netlify Forms, your own endpoint) and the method if it needs one.

7. Ship assets/script.min.js instead of assets/script.js if you want the smaller file. Change the last <script> tag in index.html:

Code snippet omitted: it ships with the download.


Brand colour: how to make it yours

The page uses exactly one saturated colour, and it is deliberately isolated so that changing it cannot break anything. Four tokens, at the top of assets/style.css:

Code snippet omitted: it ships with the download.

Set all four, and keep each -rgb triple in step with its hex. Every translucent value on the page is mixed from a triple rather than written as an rgba() literal: the hover state on the filled button, the tinted card thumbnails in the mockup, the shapes in its hero image, the washes on the project plates. Change a hex and leave its triple behind and you get a page with two brand colours on it, one of which only appears at 82% opacity.

Change those four and the following all follow automatically: both buttons, the active filter chip, the project tags, the wordmark, the availability dots, the scope-list ticks, the accent bars over the stat figures, the underline on every link, the process rail and its nodes, the build progress fill, the current-stage marker, the focus ring, the text selection colour, and the accent moments inside the browser mockup.

The rule: the accent is never a text colour

Nowhere on this page does the accent colour a glyph. Search the stylesheet for color: var(--accent) and there are no results, by design. It appears only as:

  • a fill, with --on-accent type on top: the filled button, the active chip, the project tag, the skip link, the current stage's number, text selection, the link's hover state
  • a non-text mark: rules, underlines, dots, the process nodes and rail, the progress fill, the bars beside the stat figures, the focus ring, the wordmark triangle

That is what makes the swap safe at any hue. On the shipped blue the accent measures 3.57 on the page ground, 4.25 on a card and 3.00 on the dark band, all below AA for body copy already, so it was never a candidate for small type. The part worth understanding is why it is barred from large type too.

Why the relaxed large-text exemption is deliberately unused

WCAG lets text at 24px or larger, or 18.66px bold and larger, pass at 3:1 rather than 4.5:1. It is a real rule, and the shipped blue clears it comfortably at display sizes. It is still not used anywhere here, and if you reintroduce it you will regret it the first time you change the hue.

The reason is that the exemption stops helping the moment the accent moves. The same figure that clears 3:1 in this blue measures 2.73:1 on a card in a mid orange and 1.47:1 in a mid yellow, which fails even the relaxed bar and reads as nothing at all. An exemption you cannot rely on after a reskin is not worth building on, so the two places that would obviously use it do not: the four stat figures under the hero are --ink with a short accent bar above them, and the "1.8s" in the third project card is --ink with an accent underline beneath it. In both cases the accent is doing the emphasis from a position where it carries no text.

Keep to that and swapping --accent for anything from navy to lime cannot make a word on this page unreadable.

Which --on-accent to pick

There is no single right answer, which is why it is a separate token. Measured against nine plausible mid-tone brand colours, white and near-black each win on about half of them:

Hue Example White on it #1A1917 on it Take
Blue (shipped) #2F5BE8 5.55 3.16 white
Blue #2563EB 5.17 3.40 white
Red #DC2626 4.83 3.64 white
Green #15803D 5.02 3.50 white
Purple #6D28D9 7.10 2.47 white
Teal #0F766E 5.47 3.21 white
Pink #DB2777 4.60 3.82 white
Orange #EA580C 3.56 4.94 near-black
Yellow #EAB308 1.92 9.16 near-black
Lime #84CC16 1.98 8.89 near-black

Blue, red, green, purple, teal and pink want #FFFFFF. Orange, yellow and lime want #1A1917, the same near-black the page uses for ink.

Measure your own colour against both and take whichever clears 4.5:1. Any contrast checker will do it in a few seconds. If neither clears it, your accent is in the awkward middle band and wants darkening or lightening a step; a mid-green or mid-teal at exactly the wrong lightness can miss on both sides.

Then set the triple to match:

Code snippet omitted: it ships with the download.

Two things to look at after a swap, because they are where an accent gets used hardest: hover a link in the hero (the underline grows into a full accent fill and the type flips to --on-accent), and scroll the build sequence to its third stage (the mockup's hero image is flat shapes on an accent field, and the lighter of those shapes is mixed from --on-accent-rgb).


Palette

The other seven colours, also at the top of assets/style.css, also with triples. There are no colour literals anywhere below the token block except the paper white and greys inside the browser mockup, which are meant to be a different site's colours rather than yours.

Code snippet omitted: it ships with the download.

Measured composited, as used:

Pair Ratio Carries
ink on ground 11.30 headings, prose
ink on card 13.45 service cards, the studio card
ink on sunk 9.57 the Work section
ink @ .78 on ground 6.54 section ledes, hero lede
ink @ .72 on ground 5.51 labels, meta, chips
ink @ .68 on ground 4.94 the floor: eyebrows, dt labels, years
ink @ .72 on card 6.06 scope lists, project lines
ink @ .68 on card 5.35 card kickers, service numbers
ink @ .72 on sunk 5.05 project descriptions
ink @ .68 on sunk 4.52 project years, chrome labels
paper on deep 15.03 the build heading, contact heading
paper on slate 12.78 the form

.68 is the floor for ink and there is nothing under it. It has no headroom at all on --sunk (4.52), so do not add a fainter variant, and do not lighten --sunk past #C3BFB5.

Three derived tones do the dimming, and all three resolve from the ink or paper triple: --ink-soft (.78), --ink-dim (.72), --ink-faint (.68), plus --paper-soft (.78) and --paper-dim (.70).

Bands

Every component styles itself from four band-level tokens (--tone, --surface, --hair, --wash) rather than from the palette directly, so putting a class on a section retones everything inside it with no overrides:

Class Ground Type Used by
band --ground ink Hero, Services, Process
band--deep --deep paper Build, Contact
band--sunk --sunk ink Work

Move a section between bands by changing that one class. The dark band also switches the focus ring to --paper, because an accent ring on a dark ground is not reliably visible at every hue.

If you find yourself adding a shadow to separate two things, change their tone instead. The depth on this page is three flat greige tones and hairlines, and it stops looking like itself the moment a drop shadow arrives.


Typefaces

Three, loaded from Google Fonts in one <link> in <head>, and set in three tokens:

  • Host Grotesk (--font-display) — the wordmark, the hero headline, every section and card heading, the price bands
  • Rethink Sans (--font-body) — every word meant to be read: ledes, prose, scope lists, buttons, form fields
  • Noto Sans Mono (--font-mono) — furniture only: eyebrows, dt labels, section numbers, the stat figures, week markers, project years, the URL in the browser chrome

The pairing logic is a tight grotesque display face over a slightly warmer, wider reading sans, with a mono kept strictly out of the reading column. Two constraints if you swap one:

  • If you replace Host Grotesk, replace it with something of similar width. The headline runs at up to 5rem with letter-spacing: -0.035em and a 15ch measure; a wider face will need the ceiling in .hero-title lowering, and a much narrower one will leave the hero looking under-set.
  • Keep the mono out of the prose. It is load-bearing as a signal: anything in mono on this page is a label or a number, and the moment a sentence appears in it the distinction stops working.

The headline reveal waits for document.fonts.ready before it splits the headline into lines, because line breaks measured in a fallback font get baked in. That wait is raced against a 1200ms timeout, so a blocked font host delays the reveal rather than losing it.


Sections and options

Every section is found by a data-* attribute and separately guarded. Delete any section outright and the rest of the page keeps working; the script skips anything it cannot find.

Section id Hook Delete it and…
Masthead plain markup, nothing else changes
Hero hero [data-hero] the headline reveal goes, and so do the counters and the magnetic button, since both live inside it
Build build [data-build] the pin and the scrubbed sequence are skipped
Services services [data-services] nothing else changes; it is markup plus reveals
Work work [data-work] the Flip reflow is skipped
Process process [data-process] the rail draw and the node lighting are skipped
Contact contact [data-contact] nothing else changes; the <footer> is a sibling, not a child, so it stays

[data-services] and [data-contact] are markers rather than hooks: nothing in the script reads them, because both sections are plain markup with scroll reveals on them. They are there so that every section is labelled the same way.

If you delete a section, remove its link from the masthead nav and the footer nav as well. Both are lists of plain anchors to the section ids.

1. Hero

Attribute What it does Value Default
data-hero Marks the section. Its absence skips the headline and fade sequence
data-hero-title The headline. Split into lines inside overflow masks, which rise in sequence one per page
data-hero-fade Fades up 16px behind the headline, in document order any number
data-count A numeral that counts up when it scrolls in any number
data-count-to The value to count to. Required alongside data-count number none; a non-numeric value leaves the element alone
data-magnetic The element drifts toward the pointer. Fine pointers only on the primary button

The counters and the magnetic pull are found document-wide, not inside [data-hero], so you can use either anywhere on the page.

Final values live in the markup (<span data-count data-count-to="142">142</span>), so the figures read correctly with no JavaScript; the script resets them to zero and counts up once, on a top 92% trigger. Values are rounded to whole numbers, so a decimal target will not display as one. Suffixes like % and d are separate <span class="figure-unit"> elements, outside the counted node.

The magnetic pull is capped at 18px horizontally and 11px vertically, tighter on the vertical because a button that can slide further than about a third of its own height stops feeling attached to the layout. Delete the attribute and it is an ordinary link again.

The hero's rhythm answers to viewport height as well as width. A media query at (min-width: 900px) and (max-height: 940px) steps the padding, the gaps and the display size down together, so "Start a project" stays above the fold on a 1440x900 or 1366x768 laptop instead of falling below it. It is scoped to min-width so it can never touch the phone layout, where a height query would fight the collapsing address bar.

2. Build (the signature)

Attribute What it does Value Default
data-build Marks the section
data-build-pin The element that pins. Required, or the whole sequence is skipped one per section
data-mock The browser mockup. Required, same one per section
data-wire A grey placeholder bar or block. Visible in stage 1, faded out on the way to stage 2 any number
data-real Real type. Faded in for stage 2 any number
data-colour Accent fills, the image, the tinted thumbnails. Faded in for stage 3 any number
data-stage-label One entry in the readout. Gets is-current as its stage arrives three
data-build-fill The progress bar's fill. Scaled 0 to 1 across the whole sequence optional

Tuning, all in initBuild() in assets/script.js:

Value Default What it controls
end multiplier innerHeight * 3.6 How much scrolling the three stages take
scrub 0.6 How closely the mockup follows the wheel
Wire fade at 0.6, 0.5 long, 0.012 stagger Stage 1 leaving
Real type at 0.78, 0.55 long, 0.015 stagger Stage 2 arriving
Colour at 2, 0.6 long, 0.05 stagger Stage 3 arriving
Tail at 2.7, 0.3 long The hold on the finished state before the pin releases
Label thresholds 0.2 and 0.66 When the readout switches stage

3.6 viewports is the minimum, not generosity. One hard flick of a wheel carries around three viewports under smooth scroll, so a shorter pin lets a reader clear all three stages in a single gesture, and the section then reads as the page jumping and locking rather than as a sequence. If you add a fourth stage, raise the multiplier with it and move the label thresholds to match.

The mockup's width is capped by the available height as well as by its column, so it always fits inside the pinned viewport:

Code snippet omitted: it ships with the download.

--mock-h is not set anywhere, so the 54svh fallback applies. Set it in :root to make the mockup larger or smaller without touching anything else. Everything inside the frame is sized in cqw against that container, which is what makes a 340px phone render the same composition as a 960px desktop rather than a squashed one.

3. Services

Attribute What it does
data-services Marks the section. Not read by the script
data-reveal Fades up 22px when it scrolls in, once
data-reveal-group Its direct children are revealed, staggered

The group's children are what animate, not the container, because a container that fades takes its own background with it. Reveals fire on a top 88% trigger, batched so that a row arrives together rather than one card at a time.

Each service is one <article class="svc"> with a number, a name, a "who it is for" line, a scope list and a <dl class="svc-meta"> holding the band and the timeline. Copy one to add a fifth; the grid is two columns and will simply run to three rows.

4. Work

Attribute What it does Value
data-work Marks the section
data-filter Marks the filter fieldset. Not read by the script; the CSS keys off the input ids
data-work-grid The grid whose reflow gets animated
data-work-item One project card. Hidden by CSS when it does not match
data-tags Which filters this card belongs to space-separated list, matched with ~=
.filter-input Class on each radio. The script listens for change on these

The filtering is CSS, not JavaScript. A checked radio hides everything that does not carry the matching tag:

Code snippet omitted: it ships with the download.

So it still filters with scripting off, and the chips are never a row of dead controls. When Flip is present the script records the layout before the change and animates the reflow on top of that, 0.5s, with entering cards fading up from 96% and leaving cards fading down.

To add a filter you need three things: a new <input class="sr-only filter-input" type="radio" name="work-filter" id="wf-yourtag"> plus its <label class="chip">, a matching selector line in the CSS rule above, and data-tags="yourtag" on the cards it should keep. A card can carry more than one tag.

The grid's height is locked for the duration of the tween and released on completion. Flip's absolute: true lifts the surviving cards out of normal flow while it animates, which would otherwise collapse the container and jolt everything below it. The lock takes the larger of the before and after heights, so it works in both directions.

5. Process

Attribute What it does
data-process Marks the section
data-rail-line The SVG path that draws. Skipped cleanly if DrawSVG is absent
data-step One step. Gets is-lit on arrival, top 72%
data-node The dot on the rail. Markup and CSS only, lit via its step's is-lit class
data-rail Marks the SVG itself. Markup only

The rail draws on a scrub between top 72% and bottom 80% of the list. Its trigger is the .rail-wrap element, found by class rather than by attribute, so keep that class if you restructure the section.

The path carries no dash attributes in the markup, which is why the rail is simply already drawn with no JavaScript, without DrawSVG, or under reduced motion. Add or remove steps freely: the SVG is a two-unit viewBox stretched to the list's full height with preserveAspectRatio="none", so one straight path fits any number of them.

6. Contact

Attribute What it does
data-contact Marks the section. Not read by the script
data-reveal As elsewhere

A plain HTML form with real <label> elements, autocomplete attributes, required where it matters, and no JavaScript involved in submitting it. The <footer> is a sibling of this section rather than a child, so deleting the enquiry block leaves the footer in place. It carries band--deep itself, which is what keeps its dark treatment once the section above it is gone.


The build sequence

The centrepiece is one browser mockup in one layout, moved through three fidelities by a single scrubbed timeline. Nothing is swapped in or out, and nothing is measured at runtime.

A wireframe bar and its real text share one grid cell. Every text slot in the mockup is a .mslot, which is a one-cell grid with both children placed in the same cell:

Code snippet omitted: it ships with the download.

Code snippet omitted: it ships with the download.

That is the whole trick. The bar is sized in em against the slot's own font size, so it is exactly as tall as the line it stands in, and because both children occupy one cell the line keeps the same box whichever one is showing. Crossfading them therefore moves nothing. The same pattern, using absolute positioning instead of a grid, covers the non-text elements: the button, the logo mark, the image and the three card thumbnails each have a -wire layer and a -fill or -art layer stacked on inset: 0.

The finished state is the CSS default, and the gating class winds it back.

Code snippet omitted: it ships with the download.

Read that in order: with no JavaScript the wireframes are off and the type and colour are on, so the mockup renders finished. has-js is only ever added by the small script in <head>, and it is what winds the mockup back to the wireframe so the sequence has somewhere to travel from. The reduced-motion block underneath puts the finished state back for anyone who has asked their system to stop motion, and the script's own reduced-motion branch does the same thing to the elements it would otherwise animate. A short viewport (max-height: 560px, which is a landscape phone) also gets the finished state with no pin, because there is no room to hold the mockup and the readout on screen at once.

The result is that the honest still of this section, the one a reader gets with no scripting, blocked scripts or reduced motion, is the finished website, which is exactly what the section is arguing for.


The work section and images

There is no bundled photography and no assets/img/ directory. This is deliberate rather than an omission. The six project cards are browser-framed mockups drawn entirely in CSS, so what you drop in is your own screenshots rather than a graded photo set you have to match or fight. Nothing needs regenerating and no build step is involved.

To use a real screenshot, drop an <img class="shot"> in as the first child of a card's .frame-shot. The swap point is commented in the markup:

Code snippet omitted: it ships with the download.

The CSS is already written. .shot is absolutely positioned over the plate at z-index: 2, object-fit: cover, object-position: 50% 0. There is nothing else to change.

Sizing. .frame-shot is locked to 16:10. The grid is three columns inside a 78rem shell, so a card image is roughly 360px wide at the widest desktop layout and around 560px at the two-column breakpoint. 1280x800 WebP is the recommendation in the markup: it is comfortably over 2x for every layout and leaves room if you widen the shell. Anything at 16:10 and 800px or wider is enough in practice. Because the crop is anchored to the top, put the part of the page you want seen in the top half of the screenshot; a full-page capture will show its header and hero and crop the rest.

A missing file leaves the design showing, not a broken image. The inline script in <head> listens for image load errors in the capture phase and adds is-missing, which hides the image and lets the plate behind it show through:

Code snippet omitted: it ships with the download.

It has to be in <head> and it has to use the capture phase. Image errors do not bubble, and they fire while the body is still parsing, long before assets/script.js runs at the end of it. This matters most here, because the Work section is the part you will be editing paths in.

The plates themselves are six flat compositions in the palette, one per card: a rotated accent slab, an accent field with a tinted grid, a large ink numeral with an accent underline, an accent arc, a banded stack and a bar chart. They exist so an un-swapped grid looks art-directed rather than like six empty boxes, and so a wrong path never leaves a hole. Delete a .plate outright if you would rather have nothing behind an image you are confident about. Add a seventh card by copying an <article class="work-item"> and reusing any plate--1 to plate--6 class.


Dependencies

Code snippet omitted: it ships with the download.

  • GSAP 3.15.0 and ScrollTrigger are required. Without them the script removes has-js and stops, and you get the plain document.
  • SplitText, DrawSVGPlugin and Flip are each optional and detected separately, so losing one costs you one effect rather than the page. Without SplitText the headline fades up as a block; without DrawSVG the process rail is simply already drawn; without Flip the filter still filters, it just cuts instead of animating.
  • Lenis 1.3.17 is optional. Delete the tag and the page works the same with native scroll.

These tags carry no integrity attributes, on purpose

Subresource Integrity pins a file to a hash. That is a real protection, but on a template it is a trap: the hash is only correct for one exact build of one exact version, and the moment you bump a version, switch CDNs, or copy a tag from somewhere else, the browser refuses the file and your deployed site silently loses its JavaScript. A stale SRI hash is a much more likely way for this page to break than a compromised CDN is. The page is also built to survive the failure either way: if GSAP does not arrive, the gating class comes off and everything is readable.

If you want the guarantee anyway, self-host. Download the six files into something like assets/vendor/ and point the tags at them:

Code snippet omitted: it ships with the download.

That removes the third-party request entirely, which is a stronger answer than SRI, and it is what to do if your site runs under a strict Content Security Policy. Check GSAP's licence terms for your use before you redistribute the files. If you would rather keep the CDN and add SRI, generate the hashes yourself against the exact files those URLs return, and regenerate them every time you change a version.

Two rules if you extend the page:

  • Never add anticipatePin. It pins early in proportion to scroll velocity, to cover a gap that only exists under native scroll. With Lenis pushing every frame into ScrollTrigger.update there is no gap, and the early pin is pure error: the mockup gets yanked upward exactly as the section arrives.
  • data-lenis-prevent is for vertically scrolling panels only. Putting it on something that scrolls horizontally stalls the page for as long as the pointer is over it.

Accessibility

  • No JavaScript gets the complete document: every word readable, the mockup showing its finished state, the process rail fully drawn, every figure showing its final value, and the work filter still filtering. The gating class is added in <head> before first paint, so reveal start states apply to the first frame and nothing is painted visible and then hidden, and it is removed by the script if GSAP is missing.
  • A blocked CDN is the same case as no JavaScript, deliberately. Corporate proxies and blockers make this common enough to design for.
  • Reduced motion gets the full page with nothing pinned and nothing moving. Handled twice over: a CSS block that restores every animated start state and sets the two states an animation would otherwise have set, and a matchMedia branch in the script that sets the same elements directly instead of animating them.
  • The work filter is radio inputs, not JavaScript buttons. It is a <fieldset> with a <legend>, the inputs are visually hidden but focusable, each has a real <label>, and :focus-visible on the input draws the ring on its chip. It filters with scripting off.
  • Keyboard: a skip link to the content, a visible focus ring everywhere (switched to --paper on the dark bands, since an accent ring is not reliably visible on dark at every hue), no focus traps, no custom controls, and the magnetic button releases its offset on blur as well as on pointer leave, so it cannot be left displaced after a tab away.
  • Screen readers: the mockup is one role="img" with an aria-label describing the three fidelities, and everything inside it is aria-hidden, so it is announced once as a picture rather than read out as forty fragments of another company's website. The decorative dots, plates and nodes are aria-hidden too. The studio facts, service meta and contact details are real <dl> elements, and the process is an ordered list.
  • Short viewports (max-height: 560px, a landscape phone) skip the pin and get the finished mockup, rather than a pinned section with no room to hold it.
  • The narrow phone layout hides the two non-current stage sentences in the build readout, so the mockup keeps the pinned screen; the current stage always keeps its sentence.

Smooth scroll

Lenis is on, sharing one clock with ScrollTrigger, and it is optional.

Keeping them on one clock is not decoration. Left on its own rAF, Lenis moves the page while ScrollTrigger is still reading the previous frame's position, and a pinned section is the worst case for that: the pin is measured against a scroll position that has already moved on, and the mockup judders a frame behind the page around it. The script runs Lenis with autoRaf: false, pushes every scroll into ScrollTrigger.update, drives lenis.raf from gsap.ticker, and calls lagSmoothing(0) so GSAP does not clamp the delta after a stall and desync them again. It also re-syncs Lenis to the native scroll position on every ScrollTrigger refresh, without which entering the pinned section makes the page appear to jump and then fight you.

Three ways to turn it off:

How Where Use it for
?smooth=off / ?smooth=on appended to the URL comparing both ways with no edit and no rebuild
data-smooth="off" on the <html> tag shipping the site without it
Reduced motion the visitor's system setting handled for you

The URL parameter wins over the attribute, and reduced motion wins over both; Lenis does not honour that preference by itself, so the script does it. To remove Lenis entirely, delete its <script> tag and the Lenis block in initNorthbound(). Nothing else depends on it.


Browser support

Current Chrome, Firefox, Safari and Edge. Two features are worth knowing about because they degrade differently:

  • :has() does the work filter. In a browser without it the chips still take focus and still show their checked state, but nothing is hidden, so the grid always shows all six projects. That is a mild loss rather than a broken page. Firefox gained :has() in 121; everything else has had it since 2022.
  • Container queries and cqw units size everything inside the browser mockup. Without them the cqw lengths are invalid and dropped, so the mockup's type falls back to inherited sizes and its composition loses its proportions. The three fidelities still crossfade, and the rest of the page is unaffected. Same support story: Firefox 110 and up, everything else since

Also used, all widely supported: svh units, aspect-ratio, clamp(), CSS custom properties, clip-path, text-wrap: balance (purely cosmetic where it is missing) and :focus-visible.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.