Quick Start
- Copy the whole folder to your web server or any static host. There is no build step.
- In
index.html, replace the wordmark in two places:.bar-markin the fixed bar and.cover-markon the cover. They have to match, because one docks into the other. - Replace the cover copy. The headline is three hand-broken lines, and it appears twice (once in
.plate--chalk, once in.plate--negative) so the negative stays in register. Edit both, or see "Deleting the torch" below. - Replace the rest: the lede standfirst and its two marginal notes, the four plate captions, the five ledger rows, the two quotes, and the colophon details.
- Point the contact links at your own inbox: the
mailto:on.mailto, thetel:in the details list, and the three placeholder social links (they currently point back at#colophon). - Deploy. The page works with JavaScript disabled, so nothing else is required.
The GSAP scripts load from a CDN in index.html. Swap assets/script.js for assets/script.min.js in production if you prefer the smaller file.
The two signature moments
The torch, and the negative plate
How it is built. .cover-stage holds two copies of the cover composition. .plate--chalk is in normal flow and defines the height. .plate--negative is absolutely positioned over it with an ink background, chalk type and its artwork inverted, and it carries a static radial-gradient mask. The script writes only mask-size and mask-position, from one gsap.ticker pass; that is deliberate, because a custom property inside a gradient string cannot be tweened.
What responds to what. Pointer position, aperture radius and two stretch factors live on one state object eased by gsap.quickTo. Pointer velocity biases the stretch toward the axis you are moving on and squashes the other, so a fast horizontal flick smears the aperture sideways. The rim (.torch-rim, an inline SVG circle with vector-effect="non-scaling-stroke", so it stays 1px at any scale) lags behind with a simple lerp. When the pointer has been still for a tenth of a second, an elastic tween springs the stretch back to a circle.
Tuning it, all in the torch block of assets/script.js:
| What | Where | Note |
|---|---|---|
| Aperture size | baseR = Math.max(96, Math.min(215, Math.min(box.w, box.h) * 0.26)) |
A fraction of the stage's short edge, clamped |
| Edge softness | the mask-image gradient in .has-js .plate--negative |
Widen the soft band past about 18% of the radius and it reads as a smudge rather than an edge |
| How far it stretches | mag = speed * 0.46 in stretchTargets |
0 disables the stretch entirely |
| How hard it springs back | ease: 'elastic.out(1, 0.42)' in relax |
Raise the second number to damp it |
| Rim lag | var k = 0.17 in render |
Higher is tighter; 1 removes the lag |
| Rim position on the edge | lag.r * 0.82 in render |
Match this to the gradient's opaque stop if you change it |
| Touch drift path | the two Math.sin terms in render |
The multipliers are speed, the * box.w * 0.28 terms are amplitude |
Deleting the torch. Delete the whole .plate--negative block and the .cover-tools block from index.html. The script finds no negative plate, skips the entire torch section, and the cover is just the chalk plate. Nothing else changes, and you then only maintain one copy of the headline.
The docking wordmark
A scrubbed ScrollTrigger runs from the top of #cover for cover.offsetHeight * 0.55. Its x, y and scale are functions that measure [data-cover-mark] and [data-bar-mark] live, so the end state is wherever the bar's slot actually is, at any type size or viewport. The y value also has to cancel the scroll the page performs over the range, which is why the ease is none: any easing would push the wordmark off the straight line into the slot.
At the end of the range the cover wordmark is hidden and the bar's own wordmark shown in the same frame, at the same size and position, so the handover is invisible. Both are re-measured on resize and on every ScrollTrigger refresh.
- Change the travel distance:
geo.rangeininitDock. - Change where it lands: nothing to change. Restyle
.bar-markand the dock follows it. - Remove it: delete
[data-bar-mark]from the bar. The dock is skipped and the cover wordmark simply scrolls away.
Sections and their options
Every section is independent: the script guards all of its selectors, so any of these can be deleted wholesale.
| Section | id | Hooks the script reads |
|---|---|---|
| Fixed bar | (header) | data-bar (measured for anchor offsets), data-bar-mark (the dock's target slot) |
| Cover | #cover |
data-cover-mark, data-cover-fade, data-stage, data-negative, data-torch-rim, data-torch-toggle, data-torch-label, data-lines + data-line, data-sign |
| Lede | #lede |
data-reveal on the main block and on each marginal note |
| Plates | #plates |
data-reveal-group (staggers its direct children), data-plate-art (a small scrubbed drift) |
| Ledger | #ledger |
data-ledger, data-row, data-row-rule |
| Voices | #voices |
data-reveal, data-split-lines (SplitText, line by line) |
| Colophon | #colophon |
data-reveal |
| Attribute | Put it on | What it does |
|---|---|---|
data-reveal |
any element | Fades and lifts it in once, when its top passes 88% of the viewport |
data-reveal-group |
a grid or list container | Staggers its direct children instead of itself. The container stays visible; the children are what animate |
data-split-lines |
a paragraph | Splits it into lines with SplitText and reveals them in sequence. Falls back to a plain fade if SplitText is absent. Do not put it on the drop-capped standfirst: splitting a floated first letter into per-line blocks breaks the wrap around it |
data-lines / data-line |
a headline / each line inside it | Masked line reveal. Each line needs the .mask / .mask-in wrapper pair for the overflow mask |
data-row / data-row-rule |
a ledger row / its hairline | Row-by-row reveal with the rule drawing from the left |
data-plate-art |
a plate's artwork box | A small scrubbed vertical drift, so the artwork and its margin caption are never quite locked together |
data-sign |
an SVG <path> |
Drawn on with stroke-dashoffset during the cover intro |
Adding a section is a matter of adding the hooks; nothing is registered by index or by count, so nothing breaks when you delete a block.
Palette
Three colour values, declared once in :root at the top of assets/style.css, each with an RGB triple so every translucent value derives from a token and a reskin never leaves an old hue in a rule:
Code snippet omitted: it ships with the download.
Repaint those six values and the page reskins. The header comment above :root records the measured contrast table; read it before adjusting. The constraints that matter:
- Body copy floor 4.5:1.
--dimis ink at .68 alpha, 5.13:1 on the ground, and it carries every label, caption and secondary paragraph on the page. Do not thin it: .66 measures 4.83 and .62 measures 4.28, which fails. There is deliberately no second, fainter type variant. - The accent is banned on ink, as type and as a mark: marine on ink is 1.63:1, under both the 4.5 text floor and the 3:1 non-text floor. On any ink surface the accent's job is done by chalk. That is why the ink plate artworks carry no accent, and why the pull quote on the ink inset has a chalk quote mark where the one on the ground has a marine one.
- Rules are split by job.
--ruleand--rule-faint(ink at .18 / .14, about 1.4:1) are decorative hairlines only: the plate grid, plate frames, the halftone bar. Any rule that structures content, meaning section rules, ledger rows and the bar underline, uses--rule-firm(ink at .50, 3.05:1) so it clears the 3:1 floor for non-text marks. - The aperture's marine rim straddles the chalk/ink boundary. It is decorative,
aria-hidden, and carries no information; the aperture reads perfectly without it.
If you change the ground, re-measure --dim and the accent against it before shipping, and update the table in the header comment.
Type
Two faces, five jobs.
- Prata carries every display size and nothing else: the wordmark, the cover headline, section titles, the ledger figures, the quotes, the contact address. It has a single weight, so emphasis comes from size and space, never from bolding it.
- Lexend does all the reading, and at 0.68rem uppercase with .16em letter-spacing it is also every label, caption, folio and marginal note.
Both load from Google Fonts in the <head>. If you swap faces, keep the roles: one display serif with real contrast, one neutral sans that survives being set small and wide.
The cover display is capped at 4.4rem. It is meant to read as set, not shouted.
Artwork
There are no image files. Every piece of artwork is CSS or inline SVG, so there is nothing to license, optimise or lazy-load:
- Plate I is two overlapping dot fields on ink, each with its own gradient mask, giving a halftone that fades across the frame.
- Plate II is one SVG path repeated eighteen times with
<use>at increasing offsets and opacities, plus one heavier pass in the accent. Edit the#ridgepath and all nineteen lines change together. - Plate III is a single glyph in the display face over a hairline grid.
- Plate IV is nested hairline boxes, crop marks, one solid block and a registration cross, all
position: absolutepercentages. - The cover's grid, dot field, registration mark and signature are the same kind of thing, and the signature is a single
<path>drawn on during the intro.
To swap a plate for a photograph, replace the .art div's contents with an <img>, keep the aspect-ratio on .art, and give the image alt text plus width/height.
Accessibility
- Reduced motion is honoured twice, in a CSS
prefers-reduced-motionblock and in agsap.matchMediabranch. Nothing moves, nothing is hidden, Lenis is never created, and a preference changed mid-visit restores the static page. The reveal button still works: it snaps the plate open through a CSS class instead of easing it. - Touch and coarse pointers get a real version of the signature, not a disabled one: the aperture drifts on its own path and springs to a tapped point.
- Keyboard: the reveal control is a real
<button>witharia-pressedand a label that changes to match its state. Focusing it opens the aperture in the middle of the plate; blurring closes it. Every link and button has a visible focus ring, and a skip link jumps past the cover to the text. - No JavaScript: the negative plate and the reveal control are both gated behind a
has-jsclass added synchronously in the<head>, so with scripting off the cover is simply the chalk composition with its headline set, and every section below reads in full. The same happens if the GSAP CDN is blocked, because the script removes that class when GSAP is missing. - Screen readers: the negative plate is a decorative duplicate and is
aria-hidden, as are the grid, dot fields, artwork, rim, signature and ledger rules. The cover headline is the page's only<h1>; the negative's copy of it is a<p>.
Browser support
Modern browsers. Uses mask-image (prefixed and unprefixed), CSS custom properties, aspect-ratio, 100svh, clamp() and document.fonts. Not compatible with IE11.
Dependencies
- GSAP 3.14+ (core)
- ScrollTrigger - the dock scrub, the plate drift and every scroll reveal
- SplitText - the pull quotes, split line by line (free since GSAP 3.12)
- Lenis - optional smooth scrolling, wired onto the GSAP ticker so scrubbed tweens never read a scroll position a frame behind the one being painted. Delete the Lenis block in
script.jsand its<script>tag to drop it; nothing else refers to it.
All loaded from a CDN in index.html, deliberately without integrity hashes so a future patch release cannot break your deployed copy. If any script fails to load, the page degrades to the complete static document.
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.