Quick Start
1. Copy the folder and open index.html. That is the whole site.
2. Replace the copy. Everything is plain markup in index.html, in the order
it appears on the page: masthead, lead, findings, three stages, evidence,
response, method, footer.
3. Replace the photography and the clips. Ten images and two clips, listed in Photography and clips below.
4. Repaint the four colour tokens at the top of assets/style.css. See
Palette.
5. 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.
The three CDN tags above it (GSAP, ScrollTrigger, Lenis) can stay as they are or be swapped for local copies.
Sections and options
Every section is found by a data-* attribute and 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 | masthead |
— | nothing else changes; it is plain markup |
| Lead | lead |
[data-lead] |
the clip logic is skipped |
| Findings | findings |
[data-reveal] items |
nothing else changes |
| Stage one | stage-1 |
[data-stage] |
the other two stages still run |
| Stage two | stage-2 |
[data-stage] |
the clip logic goes with it |
| Stage three | stage-3 |
[data-stage] |
— |
| Plate band | band |
[data-band] |
the parallax is skipped |
| Evidence | evidence |
[data-evidence] |
the redaction wipe is skipped |
| Response | response |
[data-reveal] items |
nothing else changes |
| Method | method |
[data-reveal] items |
nothing else changes |
| Footer | footer |
— | plain markup |
The plate band
The one full-bleed image on the page, sitting between the last stage and the documents. Every other photograph lives in the same portrait frame in the same column, and after three stages that rhythm needs breaking.
Swap the photograph by changing the src on [data-band-media], or by editing
the band-chamber entry in assets/img-manifest.json and re-running the asset
build. It is a wide crop, so give it something that reads at roughly 2.3:1.
The image is deliberately 118% of the band's height, offset upwards by the 9% overhang that leaves. The script drifts it against the scroll by exactly that overhang and no more, so an edge can never appear. If you make the band taller, leave those two numbers alone — they are what keeps the drift safe. With no JavaScript, or under reduced motion, the image simply sits centred and nothing moves.
The stages
Each stage is one <section class="stage" data-stage> containing two things:
Code snippet omitted: it ships with the download.
| Attribute | Values | Default | What it does |
|---|---|---|---|
data-stage |
— | — | Marks a section as a locked stage |
data-stage-pin |
— | — | The element that pins. One per stage |
data-frame |
— | — | One media frame. Two or more required, or the stage is left as a plain stacked figure list |
data-step |
— | — | One step of prose |
data-media |
frame index, 0-based |
0 |
Which frame this step shows. Out of range falls back to 0 |
data-frame-media |
— | — | The <img> or <video> inside a frame that takes the crossfade scale |
data-frame-clip |
— | — | Marks a frame's <video> as a clip to play while its step is current |
data-stage-count |
— | — | Optional counter; the script writes 01 / 03 into it |
Steps and frames are independent. Add a fourth step by copying a .step
block; add a fourth photograph by copying a .frame. Two steps can point at the
same frame (data-media="1" twice) if a photograph carries two beats of the
story, and a stage works fine with two frames and four steps.
Scroll travel per step is set in assets/style.css, not in JavaScript:
Code snippet omitted: it ships with the download.
Keep it well above one viewport, and measure the trigger, not the box. The pin
runs top top → bottom bottom, so the travel a reader actually gets is the
stage's height minus one viewport, shared across the steps: 150svh per step
works out at about 1.2 viewports of real travel each. At 118svh it was 0.88, and a
single hard wheel flick under smooth scroll carries roughly 3,600px — enough to
clear an entire stage in one gesture and arrive with the media already on its last
frame, which reads as the page leaping rather than as a sequence.
The redaction
Code snippet omitted: it ships with the download.
The text stays in the document — it is always there for a screen reader, for
search and for anyone reading with JavaScript off. The bar is an aria-hidden
overlay painted only under the has-js class, and the script wipes it away as
the section scrolls. Bars wipe in document order, one after another; add or
remove them freely and the timing redistributes itself.
Timing lives in assets/script.js in initRedaction(): start: 'top 72%' and
end: 'bottom 82%' set which part of the scroll drives the wipe, and scrub: 0.6
sets how closely the bars follow the wheel.
Palette
Four tokens and their RGB triples, at the top of assets/style.css. Every
translucent value on the page resolves from the triples, so a reskin is these
eight values and nothing else — there are no rgba() literals to hunt down.
Code snippet omitted: it ships with the download.
Measured composited, as used:
| Pair | Ratio | Carries |
|---|---|---|
| bone on deep | 14.0:1 | headlines, prose |
| bone @ .78 on deep | 8.9:1 | standfirst, captions |
| bone @ .62 on deep | 6.1:1 | labels, inactive steps |
| bone on panel | 12.6:1 | evidence and statement copy |
| bone @ .62 on panel | 5.7:1 | document sources, table heads |
| signal on deep | 7.7:1 | links, counters, numerals |
| signal on panel | 6.9:1 | the same, with headroom |
The accent is a discipline, not a contrast problem. At 7.7:1 and 6.9:1 the blue clears comfortably on both surfaces, so a dimmed or tinted variant is available if a section ever needs one. It still marks only the three things listed above — current step, link, redaction bar lifting — because the moment it starts appearing on rules and icons for texture, the page stops telling the reader where to look. If you repaint, re-measure signal on panel first: that is the pair with the least room, and the burnt orange this replaced sat at 4.7:1 there.
Keep --panel within a couple of steps of --deep. It is meant to read as a
plate lifted off the ground; once the two diverge, the evidence section starts
looking like a different site.
The light palette
A second palette ships with the template, and it carries its own display face
(Bodoni Moda, a high-contrast didone, in place of Antonio) so the two read as two
identities rather than one design in two colours. Put data-palette="light" on
the <html> element to use it:
Code snippet omitted: it ships with the download.
Or, if light is the only one you want, copy its eight values over the ones in
:root and delete the :root[data-palette="light"] block entirely.
Code snippet omitted: it ships with the download.
| Pair | Ratio | Carries |
|---|---|---|
| ink on paper | 14.9:1 | headlines, prose |
| ink @ .78 on paper | 7.8:1 | standfirst, ledes |
| ink @ .68 on paper | 5.5:1 | labels, inactive steps |
| ink on panel | 16.2:1 | evidence and statement copy |
| ink @ .68 on panel | 5.8:1 | document sources, table heads |
| signal on paper | 6.5:1 | links, counters |
| signal on panel | 7.0:1 |
Two things about it are worth understanding before you make a third palette.
The alphas are re-measured, not inherited. --bone-dim is .62 in the dark
palette and .68 in the light one, because dark ink at .62 on this paper is
4.60:1 — it passes by a hair and leaves nothing for a smaller size. Alphas do not
carry across grounds; measure them again every time.
The accent had to move. The dark palette's #6FB3D2 is 2.4:1 on paper and
fails outright as text, so the light palette runs a deep petrol. It reads as the
same cold marker in context and still does the same three jobs and nothing else.
What a palette must not repaint
Everything that sits on top of a photograph resolves from a separate, fixed set of tokens that no palette overrides:
Code snippet omitted: it ships with the download.
The photography is graded dark and desaturated. Flip the scrims with the ground and you get pale type on a pale wash over a dark photograph, where legibility depends on which frame happens to be showing — which is not something a stylesheet can promise. A light page carrying dark-chromed photography is also simply what print does.
The hero is the one exception, and the light palette overrides it deliberately
(:root[data-palette="light"] .lead-scrim). A dark band directly under a paper
masthead reads as two sites stacked, so the lead goes paper too. It can afford to
because of the shape of its scrim rather than its strength: the gradient is
heavy on the left where the type sits and clears completely by 76%, so the
photograph is never asked to carry type — it simply runs at close to full strength
on the right against paper. The stage captions and the band caption sit on
their images with nowhere to escape to, which is why those keep the dark chrome.
If you add a palette and want the same treatment, copy that block and re-measure;
keep anything carrying type left of 55%.
The redaction bars are in the same category: they resolve from --scrim-rgb in
both the CSS and the script, because a redaction is a black bar whatever colour
the page is.
Typefaces
Three, loaded from Google Fonts in <head>:
- Antonio — masthead, headlines, section and stage titles, finding numerals (the light palette sets Bodoni Moda 700 in the same role)
- Faustina — every word meant to be read: standfirst, prose, quotes
- Lekton — furniture only: datelines, bylines, captions, figure and exhibit labels, step counters, the document lines
Swap them in the one <link> tag and the three --font-* tokens. The pairing
logic is condensed grotesque over reading serif; if you replace Antonio, replace
it with something equally condensed or the headline scale will need rebalancing.
| Palette | Display face |
|---|---|
| Newsroom (default) | Antonio 700, a condensed grotesque |
| Light | Bodoni Moda 700, a high-contrast didone |
The face and the colours are independent tokens (--font-display and
--font-display-weight sit in the light block next to the colours), so keep the
palette and swap the face, or the other way round. Both faces are in the Google
Fonts <link> in index.html; a browser only downloads the one the page uses,
but once you have settled, trim the link to the family you kept. Faustina and
Lekton are the same in both palettes.
Photography and clips
Images are bundled, optimised WebP derivatives with the template's grade baked
into the pixels, not hotlinked originals. Sources live in assets/img-src/ and
assets/img-manifest.json records the output name, dimensions, quality and grade
for each one.
Regenerate the whole set with:
Code snippet omitted: it ships with the download.
To swap a photograph, drop a new file at assets/img-src/<name>.jpg and re-run
that command, or edit the src in the manifest first. brightness is set per
image on purpose: the set mixes riverbank daylight, a laboratory interior and a
dusk exterior, and matching perceived brightness is what makes them read as one
commission.
| File | Where | Ratio |
|---|---|---|
assets/img/lead-poster.webp |
poster for the lead clip | 16:9 |
assets/img/stage-1-a-outfall.webp |
stage 1, step 1 | 4:5 |
assets/img/stage-1-b-pipe.webp |
stage 1, step 2 | 4:5 |
assets/img/stage-1-c-bank.webp |
stage 1, step 3 | 4:5 |
assets/img/stage-2-a-lab.webp |
stage 2, step 1 | 4:5 |
assets/img/stage-2-b-poster.webp |
poster for the stage 2 clip | 4:5 |
assets/img/stage-2-c-bottles.webp |
stage 2, step 3 | 4:5 |
assets/img/stage-3-a-weir.webp |
stage 3, step 1 | 4:5 |
assets/img/stage-3-b-anglers.webp |
stage 3, step 2 | 4:5 |
assets/img/stage-3-c-ashcombe.webp |
stage 3, step 3 | 4:5 |
Two clips, both H.264 MP4, muted, with no audio track at all:
| File | Where | Notes |
|---|---|---|
assets/sennet-outfall.mp4 |
full-bleed behind the headline | loops; 10-20s is plenty |
assets/sample-draw.mp4 |
stage 2, step 2 | loops while that step is current |
The lead clip autoplays from the markup (autoplay loop muted playsinline).
It used to be started by the script instead, on the reasoning that this kept the
poster as the default; in practice a scripted play() is refused often enough —
no gesture yet, a data saver, a browser that blocks autoplay by default — that
real visitors sat looking at the poster and reasonably concluded the hero was a
still. muted + playsinline is what every autoplay policy actually asks for,
and an attribute is honoured before any JavaScript runs.
Reduced motion is therefore handled the other way round: the script removes the
attribute, pauses, and holds a play listener that pauses again. That second
part is not optional — the browser starts playback when it has data, which is
usually after your code has run, so a single pause() gets overridden a moment
later and the clip plays on regardless.
The stage-2 clip has no autoplay: it is only supposed to run while its step is
current, so the script owns it entirely.
Both use preload="metadata", so a visitor downloads the poster and a few
kilobytes of header, not the film.
Every media slot has a designed plate behind it — a graded ground, a light
source, a vignette, a fine rule texture and a Lekton frame label, all in the
palette. If a file is missing or fails to load it hides itself and that plate
shows, so the page never renders a broken-image hole. There are four plate
variants; put frame--p1 through frame--p4 on a <figure class="frame"> to
pick one.
That last bit is handled by the small inline script in <head>, which does two
things and has to be in the head to do either: it adds the has-js class before
first paint (so reveal start states apply to the first frame and nothing
flashes), and it listens for media load errors in the capture phase. Media errors
fire while the body is still parsing, long before assets/script.js runs at the
end of it, which is why that listener cannot live in the main file. If you move
scripts around, keep those seven lines where they are.
Accessibility
- Reduced motion gets the complete page with nothing pinned, nothing moving
and no clip playing: each stage becomes three figures stacked above three
paragraphs, both clips hold their posters, and the redaction bars are simply
never painted. Handled in both the CSS media query and the script's
matchMediabranch. - No JavaScript gets the same plain document. The gating class is added in
<head>before first paint and removed by the script if GSAP fails to load, so a blocked CDN degrades to the plain page rather than hiding content behind a script that never ran. - Inactive steps are dimmed, not hidden. They sit at 6.1:1, which is readable, so somebody can look ahead or behind at any point.
- Redacted text is always in the document for screen readers and for search;
the bar over it is
aria-hidden. - Every photograph has a real
alt, explicitwidth/heightto reserve its space, andloading="lazy". - Figures use
<figure>/<figcaption>, the return is a real<table>with a caption and row headers, and the byline is a<dl>. - Landscape phones (under 521px of viewport height) get the plain stacked layout instead of a pinned frame with no room beside it.
Dependencies
Code snippet omitted: it ships with the download.
- GSAP 3.15.0 and ScrollTrigger — required.
- Lenis 1.3.17 — optional. Delete the tag and the page works exactly the same with native scroll; the script checks for it.
If you keep Lenis, keep it on GSAP's clock. The script already does this, and it
is not optional decoration: Lenis runs with autoRaf: false, pushes every scroll
into ScrollTrigger.update, and is driven from gsap.ticker with
lagSmoothing(0). Left on its own rAF, Lenis moves the page while ScrollTrigger
is still reading the previous frame, and the pinned media column jitters a frame
behind the prose beside it.
Two related rules if you extend the page:
- Never add
anticipatePin. 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 — the media column gets yanked upward by up to 200px exactly as the stage arrives. - Never put
data-lenis-preventon a horizontally scrolling element. 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. The attribute is for vertically scrolling panels only.
Browser support
Current Chrome, Firefox, Safari and Edge. Uses svh units with vh fallbacks,
aspect-ratio, CSS custom properties and clamp().
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.