The guidehow this page was made
One field, seven pigments
The page is the specimen: its background is a single colour that travels through seven historical pigments as you scroll. No images, no canvas, no WebGL.
The concept
Every colour was once a place, a price and an obsession. PIGMENT tells seven of those stories as a museum catalogue: colossal serif specimen names, tracked-caps labels, specimen cards, and the raw design tokens exposed on the page as content. The exhibit itself is the background. Between chapters the field interpolates in OKLCH from one pigment to the next; inside chapters it rests on a solid plateau.
The engine: three registered channels
The field is one fixed element. Six progress channels, one per transition, are registered with @property so the browser knows they are numbers and can interpolate them. Each is driven from 0 to 1 by the scroll position of its own vault (the text-free strip between two chapters), through a named view-timeline:
@property --t3 { syntax: "<number>"; inherits: false; initial-value: 0; }
body { timeline-scope: --vault-1, ... , --vault-6; }
.vault-3 { view-timeline: --vault-3 block; }
@keyframes prog-3 { from { --t3: 0; } to { --t3: 1; } }
.pigment-field {
animation-name: prog-1, ... , prog-6;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --vault-1, ... , --vault-6;
animation-range: contain 12% contain 88%;
}animation-fill-mode: both means each channel reads 0 before its vault and 1 after it, so at any scroll position the six numbers describe exactly how far along the pigment path you are. They compose into the three OKLCH channels as delta chains, and the colour is mixed perceptually byoklch() itself:
.pigment-field {
--bg-l: calc(0.205 + 0.175*var(--t1) - 0.08*var(--t2) + 0.4*var(--t3)
+ 0.14*var(--t4) - 0.34*var(--t5) - 0.17*var(--t6));
/* --bg-c and --bg-h: same idea */
background-color: oklch(var(--bg-l) var(--bg-c) var(--bg-h));
}Because every transition animates its own registered property, the six animations never fight over a value. No JavaScript anywhere in the engine.
One deliberate subtlety: Tyrian purple is keyframed with hue -20rather than the equivalent 340, so the numeric interpolation from vermilion (hue 32) travels down through the reds instead of up through green.
The contrast engineering
A page whose background can be any colour on a continuous path must still keep every word at WCAG AA. Three decisions make that a guarantee rather than a hope:
- Chapters are ordered so neighbours share an ink where possible. The ink sequence is light, light, light, dark, dark, light, light: only two flips exist (indigo to verdigris, Indian yellow to vermilion).
- Text never sits on a moving field. Chapters keep their own solid pigment background in every mode; only the text-free 170vh vaults are windows onto the animated field. The
containrange of a 170vh element is, by spec, exactly the scroll span where the viewport is fully inside it, so every animated frame shows vault interior only. Legibility is therefore independent of animation state, at any viewport size. - Every pairing is computed, not eyeballed.
scripts/contrast.mjsconverts each OKLCH token to sRGB, computes WCAG relative luminance, and prints ratio tables for the plateaus and for the full interpolation path at eighth steps. The mid-transition dead zones (where neither ink reaches 4.5:1, for example indigo to verdigris around t 0.5 to 0.75) are exactly why rule 2 exists.
| Specimen | Field | Ink | Contrast |
|---|---|---|---|
| Bone Black | oklch(0.205 0.012 265) | light | 15.72 : 1 |
| Lapis Lazuli | oklch(0.38 0.155 264) | light | 9.11 : 1 |
| Indigo | oklch(0.3 0.085 274) | light | 12.17 : 1 |
| Verdigris | oklch(0.7 0.105 182) | dark | 6.90 : 1 |
| Indian Yellow | oklch(0.84 0.145 88) | dark | 10.69 : 1 |
| Vermilion | oklch(0.5 0.185 32) | light | 5.78 : 1 |
| Tyrian Purple | oklch(0.33 0.105 340) | light | 11.28 : 1 |
The fallback is not an afterthought
The static presentation is the default stylesheet: every chapter on its solid pigment, every vault a fixed linear-gradient(in oklch, A, B) whose shorter-hue path matches the animated route. The animated field, the reveals and the progress spine are layered on top only inside@media (prefers-reduced-motion: no-preference) and@supports (animation-timeline: view()). Browsers without scroll-driven animations, and readers who prefer reduced motion, get the same palette, the same copy, the same catalogue, complete.
Three iteration passes
The build went through three mandatory passes before anyone saw it, each with screenshots at every chapter and mid-transition, at two viewports. Pass one hunts defects: contrast at transition midpoints, blend-mode legibility, fallback quality. Pass two complexifies: richer specimen cards, marginal annotations, the ghost numerals, the swatch strips. Pass three restores cohesion and removes what pass two overdid. The log lives in ITERATIONS.md in the repository.
The complete creative process is reproducible: the design brief and the development architecture live as reusable prompts in the repository'sprompts/ folder.