046

Branding Agency Template

£10

Editorial one-page site for any studio whose work is typographic rather than photographic: identity, packaging, print. A contents index of plates you throw sideways, headings in a variable serif that set themselves as they arrive, a featured project laid out as an article, and four measured palettes.

ScrollTriggerDraggableInertiaPlugin Lenis advanced
13 more details
CustomEase draggablemomentumtouch-dragvariable-fontclip-pathmask-revealtypesettingscroll-revealstaggerkeyboard-navigationprogressive-enhancement 4 colour palettes
Branding 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 brand and identity studio: naming, identity systems, typography, packaging, editorial design and the guidelines that hold it all together. It ships as a finished site for a fictional eight-person studio, PLINTH, with real disciplines, honest timelines and actual deliverables already written, so what you open reads as a working practice rather than a grid of placeholder headings.

Read the full template overview

It is set rather than laid out. A pale sage ground, a serif that does both the display and the reading through its optical size axis, folios and hairline rules, marginalia in the outer column, small caps for furniture, and a measured reading column of about sixty-six characters. The oxblood accent is treated as a second ink rather than a button colour: it clears the AA floor on every light surface here, so it sets folios, the drop cap, small-caps labels, links and the pull quote, which is what an editorial page actually needs.

Two things carry it. The contents index is a horizontal run of numbered typographic plates that you throw sideways with momentum, the way you flick through a magazine's contents page, with a progress rail, keyboard access and a native scrolling strip underneath as the fallback. And the type sets itself: every display heading arrives a little light and settles to its resting weight once as it comes into view, then stays put. Nothing on the page follows the scroll, which is deliberate on a layout whose whole argument is that it has been properly set.

Four palettes ship, not one. Sage is the default; Press, Slate and Night are alternate token blocks you switch on with a single attribute on the html element, or copy into place and delete the rest. All four were measured on the same pairs, and Night inverts the page entirely, which is the case that proves why the type colour sitting on an accent fill is its own token.

There is no bundled photography and none is wanted: every plate and mark is set type on a colour field, which is what a branding studio's work actually is. Each plate carries a documented image swap point if you would rather use a photograph. Every section is found by a data attribute and separately guarded, so delete the contents index, the feature, the practice list, the quotes or the enquiry block and the rest keeps running.

What's included

11 items
  • A contents index of numbered typographic plates that you drag and throw with real momentum, bounded and with a progress rail, which falls back to a native scrolling strip with no JavaScript and under reduced motion
  • Display headings cut in a variable serif that arrive light and settle to their resting weight once as they come into view, reading as type being set rather than as motion that follows the reader down the page
  • A featured project set as a genuine article: drop cap, a measured column of about sixty-six characters, marginalia in the outer column, a pull quote that breaks the measure, and numbered notes with back-links
  • Four measured palettes shipping as token blocks, switched by one attribute, with the type colour that sits on an accent fill kept as its own token because a light accent needs dark type on it and a dark one needs light
  • An accent treated as a second ink rather than a button colour, measured past the AA floor on every light surface so it can set folios, labels, links and the drop cap, and explicitly barred from the dark band where it fails
  • A practice list of six disciplines set as editorial rows with timings and deliverables, not as a grid of cards
  • Text reveals built from clip-path and masked line transforms rather than a text-splitting plugin, so the markup stays plain and readable with no JavaScript
  • Touch handled properly on the index: coordinates read from the raw event, movement rather than press committing the gesture, and a direction test so a vertical swipe that starts on the strip still scrolls the page
  • One named custom ease used consistently across every reveal, wipe and settle, with a graceful fallback if the plugin does not load
  • Every measured contrast ratio written into the stylesheet header, including the two pairs that are banned and why one of them fails silently on a reskin
  • Accessible throughout: a complete static document with no JavaScript, a full page under reduced motion with the type axes at rest, a blocked CDN that degrades to the plain document, and keyboard access to every plate that brings the focused one into view

Perfect for

6 use cases
  • Brand and identity studios presenting naming, identity, packaging and guidelines work
  • Design studios and consultancies whose output is typographic rather than photographic
  • Editorial and print designers who need a site that looks set rather than templated
  • Type foundries and lettering practices showing specimens and commissions
  • Any studio that sells taste and wants a page that demonstrates it rather than claiming it
  • Small practices that need a work index, a case study and an enquiry form on one page

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 footer sits outside the enquiry section, so removing that section does not take the colophon with it.

The contents index

The default is a native horizontally scrolling strip with scroll snapping, which is swipeable, keyboard reachable and works with no JavaScript at all. Only when motion is allowed and both Draggable and the inertia plugin have loaded does the script upgrade it to a thrown, bounded track. Coordinates come from the raw pointer event rather than a cached pointer, nothing commits on a press, and the gesture has to prove itself more horizontal than vertical before it takes over, so a finger that lands on a plate and swipes up still scrolls the page.

Type that sets itself

Each display heading is found by a data attribute and given one ScrollTrigger that fires a single time as it enters. Its resting cut, optical size and width included, is read back out of the stylesheet at start-up, so restyling a heading in CSS cannot leave the script disagreeing with it. Only the weight moves: changing width would change how much room the words take and reflow the line under the reader. The axes are written by tweening a plain object and setting font-variation-settings from an update callback, naming all three axes, because writing that property wholesale overrides the font weight and optical sizing that CSS set.

Type reveals without a plugin

Headline lines rise out of overflow-hidden masks, handled so a percentage start state in CSS cannot strand them at a stale pixel offset. Section titles wipe in on clip-path. Nothing depends on a text-splitting plugin, so the markup you read is the markup that ships and a reader with no JavaScript gets ordinary headings.

Without JavaScript

The gating class goes on in the head before first paint, so reveal start states apply to the very 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 contents index is a scrolling strip, the headline sits at its resting weight and width, the article is a plain article, and every note and back-link still works.

Plugins ScrollTrigger, Draggable, InertiaPlugin, CustomEase
Difficulty Advanced
Smooth scroll Lenis integration
Colour palettes 4 ready-made palettes, each measured for AA contrast. Switch with one attribute, or copy the values you want into :root. Try them in the preview above.
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 is the variable-font heading effect doing?

Display headings are cut in a variable serif that arrives light and settles to its resting weight once as it comes into view. It reads as type being set rather than as motion chasing the reader down the page, which is the distinction a type-led studio would care about.

Does it need SplitText or a text-splitting plugin?

No. Text reveals are built from clip-path and masked line transforms instead, so the markup stays plain and fully readable with JavaScript off. One named custom ease is used consistently across every reveal, wipe and settle, with a graceful fallback if that plugin does not load.

Which of the four palettes should I ship?

Whichever suits the work; they are equal citizens, not one default and three demos. Each is a measured token block switched by one attribute, and the type colour that sits on an accent fill is its own token, because a light accent needs dark type and a dark one needs light. The stylesheet header names the two banned pairs and why.

Will throwing the contents index sideways break scrolling on a phone?

No. Coordinates are read from the raw event, movement rather than press commits the gesture, and a direction test means a vertical swipe starting on the strip still scrolls the page. With JavaScript off or under reduced motion it is a native scrolling strip.

Is there a proper case study, or just a work grid?

A real one. The featured project is set as a genuine article: drop cap, a measured column of about sixty-six characters, marginalia in the outer column, a pull quote breaking the measure and numbered notes with back-links. The practice list is editorial rows with timings and deliverables rather than cards.

Can I take enquiries through it as it stands?

Not yet. The enquiry form validates and confirms on the page, but its action is empty, so a submission goes nowhere. Wire it to a form handler or your CRM before the site is live. Studios already running new business through HubSpot or Pipedrive usually want the fields posting there rather than into a shared inbox.

Paid template

Purchase to unlock the code.

Buying Branding 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 in a browser. That is the whole site; there is no server to run.

2. Change the head. In order down the file:

Code snippet omitted: it ships with the download.

Check that there is no <meta name="robots"> tag, and leave it that way. This template ships deliberately without one, so the site you deploy is indexable from the first crawl. If you paste one in from somewhere else, make sure it does not say noindex; a noindex left in by accident is the single most common reason a new site never appears in search results, and nothing on the page will look wrong while it happens.

3. Replace the copy. It is all plain markup in index.html, in the order it appears on the page, and each section is fenced by a comment banner:

  1. Masthead: wordmark, nav, the three hand-broken headline lines, the standfirst, the facts list, the type specimen.
  2. Contents: seven plates.
  3. Feature: the Marrow write-up, its marginalia and its three notes.
  4. Practice: six disciplines.
  5. Voices: three quotes.
  6. Enquiries: the form and the contact list.

Then the colophon, which sits after </main>.

4. Give the form somewhere to go. The <form> ships with an empty action. See The enquiry form below; this is the one thing on the page that is not finished, and it has to be done before you take enquiries.

5. Repaint the seven colour tokens at the top of assets/style.css if the oxblood-on-sage palette is not yours. See Palette, and read the two banned pairs before you pick an accent.

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

Both files do exactly the same thing. assets/script.js is the commented one; keep it around, because the comments are where the reasoning lives.

The fastest route to making it yours is the masthead and the contents index, in that order. The three headline lines and the seven plates are what a visitor sees before they scroll, and they are the two blocks with the most of PLINTH left in them. Everything after the fold is prose you can rewrite at leisure.


The scripts it loads

Six tags at the bottom of index.html, above assets/script.js:

Code snippet omitted: it ships with the download.

Library Version Required? Lose it and…
GSAP 3.15.0 yes the page falls back to the plain document
ScrollTrigger 3.15.0 yes same; it is checked in the same guard as GSAP
Draggable 3.15.0 no the contents index stays a native scrolling strip
InertiaPlugin 3.15.0 no the same; the throw needs both
CustomEase 3.15.0 no every ease falls back to power3.out
Lenis 1.3.17 no native scrolling, everything else identical

Each optional plugin is detected separately, so losing one costs one feature rather than the page.

There are deliberately no integrity attributes on these tags. Subresource Integrity pins a script to one exact hash of one exact file. That is the right call for a bundle you control and the wrong one here: the moment a CDN reships a file, or you bump a version and forget to regenerate the hash, the browser refuses to run the script and your deployed site silently loses its JavaScript. The page is built so that a missing script degrades gracefully, but a stale hash means it degrades on every visit, for everyone, without an error anyone will notice.

If you want the guarantee that SRI is reaching for, self-host instead. Download the six files, drop them in assets/, and point the tags at local paths:

Code snippet omitted: it ships with the download.

Nothing in assets/script.js cares where the libraries came from; it checks for the globals and nothing else. Self-hosting also removes two DNS lookups and two third-party connections from your critical path, which is worth more than the hash was.

The two <link rel="preconnect"> tags and the Google Fonts stylesheet in <head> are the same trade in miniature. See Typefaces for self-hosting those.


Sections and options

Every section is found by a data-* attribute. Copy is markup; behaviour is attributes. There is nothing to configure in JavaScript except the numbers listed under each heading below.

The hooks, in page order

Attribute Where Read by What it does
data-masthead <section class="masthead"> script Marks the masthead and scopes the specimen readout. The heading settle itself is per heading, not scoped to this
data-lede-title <h1> script Identifies the headline, so the specimen readout follows it. Required if data-line is present; see the warning below
data-line <span class="line"> ×3 script, CSS One masked headline line. Pre-hidden by CSS, revealed by the script. Add or remove lines freely; the stagger redistributes
data-kinetic every display heading script Marks a heading that settles from the arrival weight to its resting one. Add it to any heading to include it; remove it and that heading sits still
data-axis-wght <span> in the specimen script Receives the headline's weight as it settles, rounded, then stops. Optional; delete it and the settle still runs
data-axis-wdth <span> in the specimen script Prints the resting width. Not animated: the width axis is deliberately never moved
data-contents <section class="contents"> script The contents index. The is-draggable and has-no-pan classes are toggled on this element
data-contents-viewport <div> script The clipping frame. Carries tabindex="-1", role="group" and an aria-label. Required, with the track, or the whole block returns
data-contents-track <ol> script The element that is transformed. Required
data-plate <li class="plate"> ×7 script One plate. Used for the tap test and for bringing a keyboard-focused plate into view. Any number of them
data-contents-progress <span> script The progress fill. Optional; delete it and the rail simply is not painted
data-contents-hint <p>Drag the index →</p> nothing A marker with no code behind it. The hint is hidden by CSS along with the rail when the index fits on screen. It is there so you can find the line, or hook it yourself
data-feature <section class="feature"> nothing Labels the section. No script or stylesheet reads it
data-practice <section class="practice"> nothing The same
data-voices <section class="voices"> nothing The same
data-enquiry <section class="enquiry"> nothing The same
data-enquiry-form <form> script The form whose submit is intercepted, and only while its action is empty
data-enquiry-status <p role="status"> script Where the confirmation line is printed. Optional
data-reveal anywhere script, CSS Fade and rise this element once, as it enters
data-reveal-group anywhere script, CSS Fade and rise this element's direct children, staggered. The container itself keeps its layout and never moves
data-wipe <h2 class="section-title"> ×5 script, CSS Wipe this element in from the left behind a clip-path
data-smooth <html> script "off" disables Lenis. See Smooth scroll
data-cfasync="false" the head <script> Cloudflare Stops Rocket Loader deferring the tag. Leave it alone

Do not remove data-lede-title on its own. The stylesheet hides every [data-line] under .has-js, and the script only un-hides them from inside the block that looks for [data-lede-title] first. Take the title attribute off and leave the line attributes on, and the headline is hidden with nothing left to reveal it. To make the headline static but visible, remove data-line from the three spans and keep data-lede-title. To remove the whole thing, remove both.

Reveal timings

All in assets/script.js, all safe to change:

What Trigger Duration Notes
Headline lines on load, delay: 0.08 1.05s yPercent: 1080, stagger: 0.09
Section title wipe top 88%, once: true 1.05s inset(0 100% 0 0)inset(0 0% 0 0)
data-reveal items top 90%, once: true 0.85s Batched, y: 220, stagger: 0.08
Focused plate on keyboard focus 0.55s Slides the index to bring the plate in, with 28px of clearance

Every one of them uses the same named ease, created once near the top of the script:

Code snippet omitted: it ships with the download.

It overshoots by about two per cent around a third of the way through and comes back, which is what makes a movement read as settling rather than as stopping. If CustomEase is missing the whole page falls back to power3.out and nothing else changes.

Reveals fire once, so a reader who reloads halfway down the page would otherwise find everything above them stuck at opacity zero. A refresh handler walks the list and restores anything already scrolled past. If you add reveal targets, they inherit that for free.

The enquiry form

Code snippet omitted: it ships with the download.

While action is empty the script intercepts the submit, prints a confirmation line into [data-enquiry-status] and resets the form, so nothing is silently lost during the time the endpoint does not exist yet. That is a placeholder, not a feature. Nothing is sent anywhere.

Point the form at your own handler or a form service:

Code snippet omitted: it ships with the download.

The moment action has a value the script stops touching the form entirely and the browser posts it as normal, including with JavaScript disabled, which is the state a contact form has to be correct in first.

Field names as shipped: name, organisation, email, need, message. The confirmation wording lives in assets/script.js, in the block headed 4. The enquiry form.


Deleting sections

Every section is separately guarded. Delete any <section> outright and nothing throws: each block in the script looks for its own element first and returns if it is gone, and the four sections nothing reads are simply markup.

Section id Hook Delete it and…
Masthead top [data-masthead] the headline effect is skipped; remove the nav link too
Contents contents [data-contents] the index logic is skipped. Repoint the skip link, which targets #contents
Feature feature [data-feature] nothing else changes; the seven plates link to #feature, so give them somewhere else to go
Practice practice [data-practice] nothing else changes
Voices voices [data-voices] nothing else changes
Enquiries enquiries [data-enquiry] the form logic is skipped. The colophon survives
Colophon plain markup, no hooks

Two things to tidy after any deletion: the masthead nav and the colophon nav both list all five anchors by hand, and the skip link at the very top of the body points at #contents.

The colophon is a sibling of the enquiry section, not a child of it. It sits after </main>, outside every section, precisely so that deleting the enquiry block does not take the footer with it. If you restructure, keep it there.


The contents index

The signature of the page: seven numbered plates in a horizontal run that you pull sideways and let go.

What it actually is by default

Read this before you change anything, because it is the opposite of what a horizontal carousel usually is.

The default is a native overflow-x: auto strip with scroll snapping. The markup is an <ol> of <li>s inside a scrolling div, styled with scroll-snap-type: x proximity. That version is swipeable on a phone, scrollable with a trackpad, reachable with the tab key, and works with no JavaScript at all. It is also exactly what a reader with prefers-reduced-motion: reduce gets, and what you get if Draggable or InertiaPlugin fails to load.

The thrown version is an upgrade layered on top. The script adds is-draggable to the section, which turns the viewport to overflow: hidden, switches snapping off and hands the movement to a transform on the track. It does that only when motion is allowed and both plugins are present:

Code snippet omitted: it ships with the download.

The progress rail is painted in both modes; it reads scrollLeft in one and the track's x in the other.

Touch behaviour

The one thing a horizontal strip inside a vertical page has to get right is that a vertical swipe starting on the strip must still scroll the page. A finger lands on a plate, the reader flicks up, and the page has to move. This template enforces that in three independent layers, and all three are load-bearing:

  1. touch-action: pan-y on the viewport in draggable mode, which tells the browser it may keep vertical gestures.
  2. allowNativeTouchScrolling: true on the Draggable instance, which tells GSAP the same thing.
  3. An axis test in onDrag. Nothing is committed on a press, because a press on a touch screen is very often the start of a page scroll. If the pointer proves vertical (|dy| > |dx| and |dy| > 8), the track is snapped back to where it was pressed and the drag is ended immediately; if it proves horizontal (|dx| > 6 and |dx| > |dy|) the gesture commits to the index.

Related, and the reason the code reads coordinates the way it does: every pointer position comes from the raw event, never from a cached pointer on the drag instance. When the browser reclaims a touch gesture for a page scroll, the cached value reads zero, and on a horizontal index a zero reads as a full-width throw back to the start. It is invisible on a desktop and it is the single thing most likely to break this section, so if you edit onPress, onDrag or onRelease, keep using getPoint(e) and keep null-checking it.

A tap is separated from a flick by net displacement, not by a timer: a click is swallowed only if the pointer moved more than 8px and the last release was under 500ms ago. A flick that happens to end over a plate does not open it; a deliberate tap opens it however long the finger rested. The recency half of that test is what stops a keyboard user's later Enter press being eaten by the distance left over from an earlier flick.

Trackpads and horizontal mice drive the index too, but only when a wheel event is more horizontal than vertical. An ordinary wheel over the strip still scrolls the page, which is what stops the reader being trapped inside the section.

Adding or removing a plate

Copy one <li class="plate" data-plate> block. Nothing is paired by index and nothing counts them, so seven is not special. Change five things:

Code snippet omitted: it ships with the download.

  • the field class, which paints the plate's ground;
  • the mark, which is the artwork;
  • the folio, name, disciplines and year in .plate-meta;
  • the href, which currently points every plate at #feature.

draggable="false" on the link is not decoration. Without it the browser's own native drag starts on the anchor, fires pointercancel, and both the throw and the click break. Keep it on every plate link, and on any image you add.

Field classes paint the plate ground and set the type colour on it:

Class Ground Type
field-deep --deep cream
field-accent --accent cream
field-sheet --sheet ink
field-sunk --sunk ink
field-ground --ground ink, plus a --rule-firm border so the plate has an edge against the page
field-ruled modifier adds fine horizontal rules over whichever field it accompanies; used on plate 05 with field-deep

Mark treatments are the seven typographic arrangements, one per plate: mark-serif with mark-rule, mark-stack with mark-hair, mark-specimen with mark-baseline, mark-caps, mark-wide, mark-mono, and mark-outline, which is set in transparent type with a cream stroke. mark-foot is the small-caps line pinned to the bottom-left corner of any plate; add mark-foot--ink when the field is a light one. Mix and match them freely, or write your own; each is a handful of lines in the stylesheet under 2. CONTENTS.

Sizing and the rail

Plate width is clamp(14.5rem, 60vw, 19.5rem) and the art is a fixed 4 / 5. The track uses width: max-content and pads itself by --edge, which is the same value the shell uses to align with the rest of the page, so the first plate lines up with the section heading above it rather than with the window.

When every plate fits on screen there is nothing to pull, so the script adds has-no-pan and the hint and rail are hidden. That is checked on load, on every ScrollTrigger refresh, and again once the fonts land, because the reading face changes the track's width when it arrives.

Tuning the throw

In assets/script.js, in the Draggable.create call:

Option Value What it does
throwResistance 1800 Higher is a shorter throw. Below about 800 a hard flick crosses the whole index and slams into the far edge; above about 2500 the momentum stops being worth having
edgeResistance 0.88 How much the track resists being pulled past its bounds
maxDuration 1.15 Longest a throw may run, in seconds
minDuration 0.2 Shortest
minimumMovement 2 Pixels before a press counts as a drag
dragClickables true Lets a drag start on the plate links, which are the whole surface
allowNativeTouchScrolling true Leaves vertical gestures to the browser

Bounds are recalculated on every ScrollTrigger refresh and once the fonts have loaded. Stale bounds mean the index either stops short of the last plate or throws past it into empty ground, so if you change the track's contents at runtime, call the same refresh.


Type that sets itself

Every display heading on the page, the masthead <h1> and each <h2>, is cut in a variable serif and arrives a little light. As a heading comes into view its weight settles to its resting value, once, and then the type is finished with. Nothing here follows the scroll.

Arrival weight 360, settling to each heading's own resting weight (500 in the shipped design). The masthead's specimen block prints the figure as it settles and then leaves it there.

The restraint is the point, and it is worth keeping. Driving these axes from scroll velocity works and looks impressive in isolation, but on a page like this it fights the layout: the type is never still while the reader is moving, which is exactly when they are trying to read it. On a page whose whole argument is that it has been properly set, type that keeps re-setting itself is the thing that undoes the argument. One settle, then silence.

Only the weight moves. The width axis is deliberately left alone. Changing width changes how much room the words take, so the line visibly reflows underneath the reader, which is far more distracting than a weight change of the same size.

Two things about how it is built are worth understanding before you change it.

One: the axes are not animated as a CSS property. GSAP tweens a plain JavaScript object, and an onUpdate callback writes the string.

Two: the string names all three axes, and it has to. Writing font-variation-settings overrides the element's font-weight and its automatic optical sizing wholesale, so any axis left out of the string snaps back to the font's default rather than staying where CSS put it.

Three: nothing is hard-coded per heading. Each heading's resting cut, optical size and width included, is read back out of the stylesheet at start-up. Restyle a heading in CSS and the script follows it; there is no second place to keep in step.

Which headings take part

Any element carrying data-kinetic. In the shipped page that is the masthead <h1> and all five section <h2>s. Add the attribute to another heading and it joins in. Remove it and that heading simply sits at whatever the stylesheet says.

Changing the arrival and resting weights

The resting weight lives in assets/style.css, per heading, and the script reads it from there:

Code snippet omitted: it ships with the download.

Only the arrival weight is set in assets/script.js:

Code snippet omitted: it ships with the download.

Raise ARRIVE towards REST and the settle becomes imperceptible; drop it far below and a heading arrives looking like a different font, which reads as a loading fault rather than as craft.

The specimen's readout is printed in the markup as 500 and 100, so it shows the resting values with no JavaScript and under reduced motion. If you change the resting weight, change that number in index.html too.

Turning it off

Set ARRIVE equal to REST:

Code snippet omitted: it ships with the download.

The headline still reveals on load, the specimen still prints, and no axis moves. Do not remove data-lede-title to switch it off; see the warning in Sections and options. If you want the headline completely inert, remove data-lede-title and data-line together, which leaves an ordinary <h1> of three spans.

The line reveal

The three lines are broken by hand, one per <span class="line-mask">, so the breaks are yours to choose rather than the browser's. Keep each line under about twenty characters or a narrow phone wraps one of them and strands a word on its own; the headline's lower clamp() bound is set by whichever line is longest.

The reveal uses fromTo, never from. The stylesheet has already set opacity to zero under .has-js, and a from tween would animate zero to zero and leave the headline hidden for good. It also states both yPercent and y, so nothing is left holding a percentage that the browser has already parsed into a stale pixel offset.

Under reduced motion none of this runs: the lines are set visible immediately and the axes are never touched, so the headline stands at the resting cut the stylesheet already gave it.


Palette

Seven values at the top of assets/style.css, each with its -rgb triple:

Code snippet omitted: it ships with the download.

The ground is a pale sage, deliberately a colour rather than an off-white. The raised surface is lighter than the ground and the recessed one is darker, so depth reads as light rather than as a shadow. There are no shadows anywhere on the page.

A reskin is repainting those seven values with their -rgb triples in step, and nothing else. Every translucent value on the page, every hairline, every dimmed type colour, the focus ring and the plate fields, is mixed from a triple: there is not one rgba() literal in the stylesheet. That is the only reason a reskin is a short edit instead of a hunt.

Measured contrast

Composited, at the size each pair is used. These figures are the record kept in the stylesheet header; treat that header as the source and update it when you repaint.

Pair Ratio Carries
ink on ground 12.25 all prose
ink on sheet 13.98 feature prose
ink on sunk 10.51 practice list
ink @ .78 on ground 6.93 standfirst
ink @ .72 on ground 5.76 labels, meta
ink @ .66 on ground 4.84 the floor
ink @ .72 on sheet 6.22 marginalia
ink @ .66 on sheet 5.10
ink @ .72 on sunk 5.27 the floor on sunk
ink @ .66 on sunk 4.50 banned, no headroom
cream on deep 15.90 voices, colophon
cream @ .78 on deep 10.01 attributions
cream @ .70 on deep 8.30 the quietest type
accent on ground 7.73 folios, drop cap, links
accent on sheet 8.83 pull quote, notes
accent on sunk 6.60 practice numbers
cream on accent 10.00 type on an accent plate
accent on deep 1.59 banned, type and mark

The accent is a second ink, not a button colour

This is the decision the whole page is built on. Oxblood measures 7.73 on the ground and 8.83 on the sheet, comfortably past the 4.5 floor for body text, which means it is allowed to set type. So it does: folios, small-caps labels, the drop cap, the pull quote, links, plate numbers, note numbers and back-links, and the progress fill.

That is what a second ink is for on an editorial page, and it is why the page does not need buttons, cards or coloured panels to establish hierarchy. It is still disciplined. One accent, used for emphasis and structure, never as a wash behind body copy, and never as a large flat field except on a contents plate, where the type sitting on it is cream at 10.00.

Where the accent is banned: the dark band

Oxblood on --deep measures 1.59:1. That is under the 4.5 floor for text and under the 3:1 floor for non-text marks, so it fails as type, as a rule, as an icon and as a focus ring. It therefore appears nowhere in Voices and nowhere in the colophon. On the dark band the accent's job is done by cream, and the focus ring is overridden explicitly:

Code snippet omitted: it ships with the download.

This is the pair that fails silently on a reskin, and it is the first one you must re-measure. Nothing in a browser will tell you it broke. The page will still render, the dark band will still look designed, and a keyboard user will simply have no visible focus ring on two of your sections while everyone else sees a page that appears fine. Measure your new accent against your new --deep before you measure anything else, and if it does not clear 3:1, keep using cream there.

Two more rules the stylesheet enforces

--ink-faint (ink at .66) is banned on --sunk. It measures 4.50 there, which passes by nothing at all and leaves no headroom for a smaller size or a lighter rendering. Use --ink-dim (.72, 5.27) on the practice band instead; the stylesheet already does, and the two places it matters are commented in place.

Only --rule-firm may structure content. Non-text marks have their own floor of 3:1 and their own token:

Token Value Use
--rule-firm ink @ .55, 3.59 on the ground section rules, list rows, the masthead rule, input borders, the plate meta rule
--rule ink @ .22 decorative hairline, carries no information
--rule-faint ink @ .13 the same, quieter
--rule-neg cream @ .5 rules on the dark band and on dark plates
--rule-neg-faint cream @ .2 the same, quieter
--accent-faint accent @ .16 never behind body copy

--rule and --rule-faint are decorative and must never be the sole carrier of a boundary. If a rule is the only thing telling the reader that one row ends and the next begins, it has to be --rule-firm, because at .22 the boundary simply is not there for a reader with reduced contrast sensitivity or a screen in sunlight.

What the page refuses to look like

Worth stating, because it is what keeps a reskin from drifting: no gradient meshes, no glassmorphism, no glowing borders, no neon, no decorative drop shadows, no stock photography, no card grid standing in for a layout, and none of the centred arrangement every generated landing page arrives in. The headline caps at 5.5rem. This page is set, not shouted.


Palettes

Four palettes ship: the default Sage, plus Press, Slate and Night. They live as :root[data-palette="..."] blocks at the foot of assets/style.css and they are part of the template, not preview scaffolding.

To use one, set the attribute on the <html> element:

Code snippet omitted: it ships with the download.

Or, once you have chosen, copy that block's values up into :root, delete the alternates section, and you are back to a single palette. That is the tidier end state and it is what most sites should ship.

Each block restates every colour token and its --*-rgb triple. Every hairline, dimmed type colour, focus ring and plate field re-resolves from those on its own, so there is nothing else to change anywhere in the file.

Palette Ground Accent Reads as
sage (default) #D2DACD pale sage #6B2135 oxblood Quiet, printerly, a colour rather than an off-white
press #E8E4D9 warm ivory #24406E ink blue Letterpress and paper; the most conventional
slate #CBD2D6 grey-blue #7E3015 burnt rust Cooler and more industrial
night #1A1D18 near-black #C9915A warm ochre The page inverted

All four were measured on the same pairs as the default. Every pair that carries type clears 4.5:1 and every rule that structures content clears 3:1.

Two things to know before you invent a fifth

The accent against --sunk is the tightest pair in every palette. That is where the practice numbers sit. Slate's rust is deliberately darker than it looks like it needs to be: a brighter one measures 4.38:1 there and fails, where the shipped value measures 4.95:1. Check that pair before you check any other.

--on-accent may have to move, and Night is the case that proves it. On a dark ground the accent has to be light enough to read against it, which makes it far too light to carry cream type: cream on Night's ochre is 2.47:1 and fails badly, so --on-accent becomes a dark value and measures 6.23:1. That token exists precisely because one value cannot serve both a dark accent and a light one. If your accent is light, set it dark, and check it.

Note also that Night inverts what --ink and --cream mean in practice. --ink is still "the type on the ground", but the ground is now dark, so --ink is a light value. The token names describe the job, not the brightness.


Typefaces

Two, and no more, loaded from Google Fonts in one <link>:

Code snippet omitted: it ships with the download.

Roboto Serif, variable across three axes: optical size 8 to 144, width 50 to 150, weight 100 to 900. It does both the display and the reading, which is the whole pairing logic. Display sizes take a high optical size and a slightly narrower fit, written explicitly wherever they appear:

Code snippet omitted: it ships with the download.

Reading sizes take the low end of the axis automatically, via font-optical-sizing: auto on <body>, which lets the optical size follow the font size down to around 17. That is what a real text family does, and it is why one serif can carry a 5.5rem headline and a 1.07rem paragraph without either looking like the wrong size of the other.

Source Sans 3 is furniture only: labels, folios, meta lines, captions, nav, plate disciplines and years, form controls, the colophon. Nothing in the reading column competes with the prose. If you find yourself reaching for the sans inside a paragraph, that is a signal the paragraph wants a different serif treatment instead.

Both are declared as tokens, so a swap is two lines:

Code snippet omitted: it ships with the download.

If you swap the serif, it must be a variable font with a width axis. This is the one constraint the page cannot absorb. The headline effect animates wdth and wght; a static serif gives it nothing to move, and the headline will simply sit at its default cut while the specimen prints numbers that mean nothing. Check three things on any replacement:

  1. It has a wdth axis with real range. Roboto Serif runs 50 to 150 and this page uses 76 to 128. A face with a 90 to 110 axis will animate, but the effect will barely register.
  2. It has a wght axis. Almost every variable serif does.
  3. It has an opsz axis, or you accept losing the display-versus-reading distinction. Without one, every font-variation-settings declaration in the stylesheet needs its opsz term removing, and you will want to rebalance the display sizes by hand.

Then update the axis values in the stylesheet (there are roughly a dozen font-variation-settings declarations, all named in place) and the OPSZ constant in the script.

Swapping the sans is unconstrained; it does no animated work.

To self-host the fonts, download the variable files, write your own @font-face rules with font-display: swap, and delete the Google <link> and the two preconnect tags. Keep font-optical-sizing: auto on <body> and keep the full axis ranges in the @font-face font-variation-settings descriptors, or the display cuts will clamp.

The script refreshes ScrollTrigger and recalculates the index bounds once document.fonts.ready resolves, because the reading face changes every measured height on the page and the width of the contents track. That works with any loading strategy; you do not need to do anything for it.


Images

There is no bundled photography, no assets/img/ folder, and none is wanted.

That is a design decision, not an omission. Every plate and mark on the contents index is set type on a colour field, which is what a branding studio's work actually is. A studio that sells taste showing seven stock photographs of a laptop on a desk is arguing against itself. It also means there is nothing to license, nothing to optimise, nothing to grade to match, and no image weight at all on first load.

If you would rather show photographs of your own work, every plate has a documented swap point and the CSS for it is already written.

The swap markup

Commented out inside the first plate in index.html:

Code snippet omitted: it ships with the download.

Uncomment it, set src, and it covers the designed plate. Repeat per plate. Four details in that tag are doing work:

  • draggable="false" stops the browser's native image drag, which would fire pointercancel and break both the throw and the click on that plate.
  • width and height reserve the space so nothing reflows as the file arrives.
  • loading="lazy" keeps plates off screen out of the initial load. The index is horizontal, so most of them are.
  • alt="" is correct only while the plate art stays inside a container marked aria-hidden="true". If your photograph carries information the plate's meta line does not already give, remove the aria-hidden from .plate-art and write a real alt.

The CSS that is already there

Code snippet omitted: it ships with the download.

The photograph is absolutely positioned over the whole plate and cropped to fill, above the designed marks in the stacking order.

The is-missing handler

The small inline script in <head> listens for image load errors in the capture phase and tags any failed <img> with is-missing, which hides it. A missing or broken file therefore reveals the designed plate underneath rather than a broken-image icon. Every plate is a complete, finished piece of design on its own, so that fallback is not a degraded state, it is just the plate.

That listener has to be in <head>. Media load errors do not bubble, hence the capture phase, and they fire while the body is still parsing, long before assets/script.js runs at the end of it. The same inline script adds the has-js class before first paint. If you move scripts around, keep those lines where they are.

Recommended dimensions

The CSS gives every plate aspect-ratio: 4 / 5 and the plate is at most 19.5rem wide, so:

Slot Ratio Suggested source Notes
Plate photograph 4:5 portrait 900 × 1125 Covers a 19.5rem plate at 3× on a high-density screen with room to spare

Serve WebP or AVIF. There is no build step and no manifest, so whatever you put in assets/img/ is what ships; compress before you copy them in.


Accessibility

No JavaScript gets the complete document. There is nothing on this page that only exists once a script runs: the contents index is a native scrolling strip, the headline sits at its resting weight and width, the article is an article, the notes and their back-links are ordinary anchors, and the form posts to whatever you gave it. The gating class that pre-hides reveal targets is added in <head> and never appears without scripting, so none of the hidden start states apply.

A blocked CDN degrades to that same plain document rather than to a blank one. The script's very first act is to check for GSAP and ScrollTrigger together, and to remove has-js if either is missing:

Code snippet omitted: it ships with the download.

Registering the plugins inside that same guard is deliberate. A bare registerPlugin would throw on a partial CDN failure, with the page already half-hidden by has-js and nothing left to un-hide it.

Reduced motion gets a complete, static, readable page. Every reveal target is restored by the stylesheet's prefers-reduced-motion block; the headline stays at its resting axis values because the script never touches them in that branch; the contents index stays the native strip; Lenis is never started. The matchMedia condition set covers both states, no-preference and reduce, so exactly one branch always matches on every device. A condition set that matches nothing means GSAP never calls the function at all, and with the stylesheet pre-hiding every reveal, that would ship a blank page.

Keyboard. A skip link jumps to the index. Every plate is a real anchor and tabs in document order. Focusing a plate that is off screen brings it into view, tweened on the same ease as everything else. That is written by hand and it has to be: the viewport is overflow: hidden in draggable mode, which does not stop the browser scrolling it to reveal a focused child, it only makes that scroll invisible and silently desyncs the strip from the transform. So the stray scroll is zeroed and the movement is done properly.

Only a keyboard focus may move the index, tested with :focus-visible. Pressing a plate with a mouse or a finger focuses it too, and without that test the pressed plate would be scrolled back into full view the moment you let go of a drag, which reads as the whole index springing back to where you started.

The notes in the feature are a real ordered list with ids. Each reference in the prose is an anchor to its note, and each note carries a back-link with an aria-label ("Back to reference 1"), so a reader can go down and come back without losing their place.

Other structure worth keeping if you edit:

  • Sections are <section> with ids; the nav and colophon nav are real <nav> elements with aria-labels.
  • Facts and contact details are <dl>s; quotes are <figure>/<blockquote>/ <figcaption>; the type specimen is a <figure> with a <figcaption>.
  • The contents viewport is role="group" with an aria-label, so its purpose is announced.
  • The enquiry status line is role="status" with aria-live="polite", so the confirmation is read out without stealing focus.
  • Every form control has a real <label> bound by for, with autocomplete where one applies.
  • The focus ring is 2px solid var(--accent) with 3px offset everywhere except the two dark sections, where it is cream. Do not remove it.
  • Purely decorative type, the plate art and the specimen glyph, is aria-hidden="true" so a screen reader is not read a wall of monograms.

Smooth scroll

Lenis is loaded and optional. Delete its <script> tag and the block in assets/script.js that starts it and the page works identically with native scroll; nothing below depends on it.

Three ways to control it, in order of precedence:

Lever Effect
prefers-reduced-motion: reduce Lenis never starts, whatever else is set
?smooth=off / ?smooth=on in the URL Wins over the attribute. Use it to compare a page both ways with no edit and no rebuild
data-smooth="off" on <html> Ships the page without smooth scroll

Reduced motion is checked in the same expression, because Lenis does not honour it by itself.

If you keep Lenis, keep it on GSAP's clock. The script already does this and it is not decoration:

Code snippet omitted: it ships with the download.

Left on its own rAF, Lenis moves the page while ScrollTrigger is still reading the previous frame's position. Anything reading the scroll a frame late, which is what the headline does, then reads it a frame late and stutters. lagSmoothing(0) stops GSAP clamping the delta after a stall, which would desync them again.

There is one more line that is easy to think is redundant:

Code snippet omitted: it ships with the download.

A refresh restores the native scroll position itself. Lenis is still lerping toward its own older target, so without this it drags the page back afterwards and the scroll appears to fight the reader.

anchors: true is what makes the masthead and colophon nav links glide rather than jump. The stylesheet also sets scroll-behavior: smooth and scroll-padding-top: 2rem on <html> for the no-Lenis case, and turns the first of those off under reduced motion.

Two rules if you extend the page:

  • Do not add anticipatePin to any ScrollTrigger. It pins early in proportion to scroll velocity, to cover a gap that native scroll has and Lenis does not, so with Lenis driving it is pure error and yanks the pinned element as it arrives.
  • Do not put data-lenis-prevent on a horizontally scrolling element, including the contents viewport. Lenis drives the vertical axis; handing the wheel to something that can only consume horizontal movement stalls the page for as long as the pointer is over it. That attribute is for vertically scrolling panels only.

Browser support

Current Chrome, Firefox, Safari and Edge, on desktop and mobile. Everything below degrades rather than breaks, but it is worth knowing what degrades to what.

Feature Used for Without it
Variable font axes (font-variation-settings) the heading settle, and every display cut on the page The declarations are ignored and the serif renders at a static weight. Headings still set, still legible, just not tuned. The headline effect has nothing to move; the specimen still prints numbers, which is the one place it would look odd
clip-path the section-title wipe The declaration is dropped, so the title is simply visible from the start. Nothing is hidden
CSS scroll snapping the contents strip in fallback mode The strip still scrolls horizontally, it just does not settle on a plate
aspect-ratio plate art, keeping every plate 4:5 Plate art collapses to its content height. Set an explicit height on .plate-art if you need to support a browser this old
:focus-visible keeping mouse and touch presses from moving the index The script catches the exception and assumes keyboard, which is the safe direction: focus still brings a plate into view
overflow-x: clip stopping horizontal overflow on <html> and <body> Falls back to no clipping. Add overflow-x: hidden if you see a horizontal scrollbar
overscroll-behavior-x stopping a swipe on the index triggering a back gesture The browser's own edge gesture may fire
-webkit-text-stroke the outline mark on plate 07 The type renders transparent and the mark disappears. Give that plate a different treatment if it matters

Also used throughout, and safe in every current browser: CSS custom properties, clamp(), min()/max(), CSS grid, ch units, font-variant-numeric, scroll-padding-inline-start, and document.fonts.ready (guarded, so the page works without it).

The page is built mobile-first in the sense that matters: there are three breakpoints (64rem, 52rem, 40rem), the marginalia move from an outer column to an accent-ruled note above the paragraph on narrow screens, the discipline rows drop their third column, and the contents index behaves the same on a phone as on a trackpad.

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.