golden:webgl §7 · 4 checks
What should reduced motion do to a canvas hero?
Two answers ship commonly and both are wrong. The first leaves the hero running, because a media query in a stylesheet reaches nothing that a JavaScript runtime draws into a GPU context — the preference is set, the page looks identical, and the visitor who asked for less motion gets exactly as much of it. The second removes the hero, which trades a moving image for a hole and takes the page's whole first impression with it.
The answer here is one whole frame, and then nothing. Under reduce the portrait, the halftone and the docked pose are all painted; the pointer parallax and the fluid wake are not; and no frame subscription is held. That is decided in a single predicate in the hero's runtime, where the preference is one of four independent reasons to stop, and the else branch is what makes the outcome a settled state rather than a blank one: unsubscribe the frame phases, close the pointer, then render once more if the hero is still inside its gate.
The rest of this article is that decision generalised across nine subsystems, the three defects it took to get right, and the numbers underneath the four below.
- 61 gate checks naming the preference of 817 across 17 rosters
- 7 canvases still carrying an image 1 WebGL hero, 1 dot-field, 5 Rive arrows
- 0 frame subscribers held at every one of 12 scroll stops
- 16 reduce blocks in the stylesheets spread over 17 of 42 sheets
src/styles/base/reset.css:38-49
The kill switch is right, and it reaches less than you think
The snippet below is shipped here unchanged, in the first cascade layer, and nothing in this article is an argument against it. It is the floor. What is worth knowing is why it sits in the first layer and what it provably cannot touch.
Cascade-layer precedence reverses for important declarations: a normal declaration in a later layer wins, an important declaration in an earlier one does. The layer order is reset, tokens, base, layout, components, sections, utilities, overrides — so this is the strongest important rule in the sheet, and a unit test asserts both halves of that: layer zero is reset and it contains the duration line, while the last layer carries no important declaration at all.
/* src/styles/base/reset.css — the FIRST cascade layer. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* documented exception: user preference outranks design */
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* For important declarations the layer order REVERSES: the declaration
in the earliest layer wins (CSS Cascade 5, section 6.1). Nothing in a
later layer can out-rank this at any specificity — and the correct
escape hatch is therefore a NORMAL declaration in the last layer.
Reaching for important there makes the rule weaker, not stronger. */
The two lines that are not about duration are load-bearing. 0.01ms rather than 0s preserves the transitionend and animationend events, so a state machine waiting for a transition to finish keeps advancing; animation-iteration-count: 1 stops an animation declared infinite from becoming a very fast flicker at 0.01ms, which is the opposite of what was asked for. Chris Coyier, who popularised the snippet, records in the same post that it works only where motion is entirely CSS-driven and that with JavaScript-powered effects it might make animations fast and dizzying.
Putting the switch first has an honest cost, and this repository records it rather than working around it. The loading overlay was specified to answer shortened, at 0.3 s — and that is unreachable from any later layer, because a normal declaration loses to the important one and an important declaration in a later layer is weaker still. So under reduce the mark is settled rather than drawn and the burst is instantaneous. It is a deliberate regression against the reference, left in place because the user's preference outranking the design is the correct outcome.
One inference belongs here rather than a measurement. Three later-layer rules in this repository set transition-duration: 0.01s under reduce, and by the cascade rule the paragraph above states, they are normal declarations dominated by reset's important one. No gate reads a computed transition duration under reduce anywhere in the harness, so that follows from the specification rather than from a browser — which is exactly the kind of rule a codebase accumulates and never notices is inert.
home-hero.css:329-353
Duration is the wrong lever — the frame you land on is the question
The hero carries a wireframe that draws itself and erases itself on an 18-second cycle. Collapse that cycle to 0.01ms and every stroke lands on whichever keyframe happens to be there, which for this animation is the erased one: the blanket, applied correctly and doing exactly what it says, leaves an empty board. Nothing about the duration is negotiable either, because that important declaration is the strongest in the sheet.
One property defeats it, and only one. animation-name: none takes the animation out of play entirely, and a duration cannot revive an animation that has no name.
/* src/styles/sections/home-hero.css — the hero wireframe.
The reset layer would collapse the 18 s cycle to 0.01 ms and land
every stroke on the ERASED keyframe, leaving an empty board. */
@media (prefers-reduced-motion: reduce) {
.wfx-e {
animation-name: none;
stroke-dashoffset: 0;
}
.wfx-g2,
.wfx-g3,
.wfx-g4 {
display: none;
}
}
/* One group is left fully drawn, which is what the artboard is for. */
The same trap catches the orientation notice, where three elements animate a phone turning upright with an arc drawing behind it. Their first frame is the device lying down with no arc — the one pose that carries no information — so the reduce block parks all three at the finished state instead: the phone upright, the mark upright with it, the arc fully drawn and its head lit. That is end-state in the four-answer vocabulary, and the reasoning is the general case: the information is a destination rather than a journey, so a reader who asked for less motion loses the gesture and keeps the meaning.
The third instance is smaller and easier to miss. The code panel's caret blinks under a keyframes rule the typing controller never claims, so with the typing chain never started the blink would have been the one thing still moving on the page. It is silenced by name in the section's own reduce block, for the same reason the wireframe is.
motion-architecture.md:451-460
Four answers, and why runs needs a written justification
The preference is not a boolean and the specification does not describe it as one. Media Queries Level 5 defines it as detecting a desire for less motion on the page, and MDN describes the author's obligation as an interface that removes, reduces or replaces motion-based animations — three permitted responses rather than one. That is the external backing for a taxonomy, and this site declares one of four answers for every animated effect.
| Answer | What it means | Where it is used here |
|---|---|---|
| off | Never starts. The element renders in its settled end state | The hamburger morph, the rail's touch drag, the pointer-tracking plate |
| end-state | The animation is skipped and its final frame is applied at once | The WebGL hero, the five Rive arrows, the dot-field, every entrance reveal |
| shortened | Runs, at a reduced duration | Named for the loading burst, and recorded as unreachable from a later layer |
| runs | Runs unchanged, and requires a written justification | Exactly one scene site-wide: the body-colour chapter |
The declaration is not a review convention. An effect cannot be registered with the motion runtime without a reset and a dispose function, and the registration throws a TypeError if either is missing — an effect whose honest answer is nothing writes an empty function with a comment saying why. Silence is a crash rather than a default, which is what turns the architecture rule into something a build can hold.
The one runs on the site is a colour crossfade tied to scroll position, and it earns the exemption with a measurement rather than an opinion: nothing translates, rotates or scales, so there is no motion to reduce. Switching it off was measured to break the page at 390 px, where the reduced sheet collapses the ring's wrapper to one viewport, the chapter's progress source goes to zero and the background holds rgb(237, 245, 243) through the entire chapter. The committed gate asserts the opposite condition, with the ring pinned and travel at zero: the body's computed background must still darken past a red channel of 120 by the deck.
7 canvases · 3 runtimes · 15 consumers
Subsystem by subsystem, what reduce actually does
One preference read reaches fifteen runtime modules, and each of them settles a different surface. The table is the reference: the answer, what the settled state is made of, and the committed check that would fail if it were not.
| Subsystem | Answer | What the settled state is | Committed checks |
|---|---|---|---|
| WebGL hero, Three.js | end-state | One whole frame at zero delta; more than half the canvas lit; no pointer subscribed | 5 |
| Rive arrows, Canvas2D | end-state | Ten advances of one sixtieth of a second, applied and drawn once | 3 |
| Dot-field, Canvas2D | end-state | The resting scatter with every dot sampled, the cursor ring parked outside the field | 5 |
| GSAP reveals | off | Not installed at all: text unclipped, wipers absent, everything marked in | 3 |
| ScrollTrigger scrubs | off | The section's authored pose — the ring frozen at the stylesheet's minus 20 degrees | 6 |
| Lenis smoothing | off | Never constructed: native scroll, instant, with every method still working | 1 |
| Credential deck | off | Six complete cards in a plain column, the credential last, no pause control | 7 |
Two rows are worth expanding, because they show what a settled state costs to build. Rive's answer is declared as data on the contract rather than decided in a branch — the shipped arrow contract carries settle — and settling means advancing the state machine ten times at one sixtieth of a second and drawing once. The bound is fixed rather than a loop that waits for the machine to stop changing, because a looping state machine never reaches that condition. Ten steps is enough for a two-state idle transition to land.
The dot-field has a dedicated still mode, and it is emphatically not frame zero. In normal running a third of the dots resample their drift vector each frame and the rest reuse the last one, which is in the shipped look rather than bolted onto it — so frame zero is a third of a picture. Still mode samples every dot and parks the cursor ring far outside the field, so the reduced-motion frame is the resting scatter rather than a frozen cursor blob. The gate checks the fill rather than the loop: the same painted count and mean alpha 900 ms apart, and the field still in the active set, because a preference switch that un-mounts the field takes the header's ink reading with it.
pinTime(1.5) against pinTime(9)
The half-frame defect, and the question that caught it
The hero's settled frame was wrong for months and looked right the whole time. It was found by asking the frame to be deterministic rather than by looking at it, which is the transferable part of this story: on a canvas, correctness questions you can answer by eye are the ones already answered.
- The implementation that looks correct Under reduce, unsubscribe the frame phases and then call the write phase once. It paints, the canvas is not blank, and a screenshot of it is convincing.
- The latent defect Write composites with whatever the last compute phase left behind. Where no compute has ever run, that is a time uniform of 0 and a null background-noise texture — so the settled frame was missing the halftone's own field, which is most of what the hero looks like.
- The question that surfaced it The harness asked for determinism instead of beauty. Two captures at pinned times of 1.5 s and 9 s came back byte identical, and a pinned time that changes nothing is a uniform that is not reaching a shader.
- The fix Run compute and then write, at a delta of zero. Every damper holds its value, because damping across no time returns the value it started with, and the elapsed clock does not advance. It is the same frame completed rather than the next one.
- The gate that makes the claim provable Pin 3 seconds, render once, then assert the scene's time uniform reads 3. Pinning first is required: under reduce an unpinned time is legitimately 0, so reading it there proves nothing either way. A half-frame leaves it at 0 with the noise texture null.
- Read back, never screenshot The drawing buffer is not preserved, so the canvas contents are undefined once the compositor has taken them and an element screenshot is a coin toss. Rendering and reading pixels in the same turn is the only honest capture.
The roster around that fix asserts four things and none of them is a picture: more than half the canvas is lit, the frame is a whole frame, no frame is held and no pointer is subscribed, and the frame count is unchanged 1,200 ms later. The last one matters more than it sounds — a renderer that quietly ticks on by itself satisfies every static check you can write about the image.
Reading pixels back rather than diffing screenshots is the same discipline a canvas needs for regression testing, which has its own numbers here: the noise floor of a canvas screenshot.
home-journey.css:569-605
The half no duration rule can reach: layout and state
Three sections of this repository arrived at the same finding independently, in their own words: the switch cannot undo layout, and it cannot undo state. A sticky plate still pins. A transform written by a controller still holds. A custom property scrubbed to some mid-scroll value keeps whatever was last written to it. None of those is an animation by the time the preference is read, and none of them has a duration to collapse.
The journey rail is where that cost a measurable amount of content. It is a horizontal system — four cards on a 4,052 px track inside a 1,440 px viewport — and the only thing that brings the other three into view is a scrubbed transform. Switching that transform off for reduce is right, because a scroll-driven sideways slide is precisely the motion the preference is about; switching only that off clipped three quarters of the section behind the body's horizontal overflow. Measured before the layout rule existed, the document came out at 14,884 px instead of 18,709, and the missing 3,825 were the chapters nobody could reach.
A shorter document under reduce is correct and the gate says so explicitly: measuring the reduced page against its animated length would be asserting that reduced motion changes nothing, which is the opposite of the point. What must hold is that no content is lost, so the check collects every journey panel and requires each one to have area, to be displayed and visible, and to sit inside the document's own horizontal band. The fix gives reduce the same presentation a phone gets: the track becomes a column.
The state half of this produces a worse failure than a missing card. On the projects rail the reduce block un-sticks the stage, restores horizontal overflow and scroll snapping, sets the track transform to none, and puts touch-action back to auto — all so that a stale attribute, left by a preference changed mid-session before the runtime has torn down, cannot leave a reader inside an eight-screen pin they cannot scroll out of. Restoring touch-action is not decoration: the drag script is never installed under reduce, so an inherited pan-y would forbid the one gesture the fallback exists to allow.
The site this one replaced had the subtlest version of this class. Its page-transition burst shortened from 0.68 s to 0.3 s under reduce while the visibility transition it was paired with stayed at 0.68 s, so for roughly 380 ms after the overlay looked gone, a reduced-motion visitor's clicks landed on an invisible full-viewport layer. A partially applied preference desynchronises two timings that were only ever correct together, and it is visible neither in a screenshot nor to an automated audit.
The rail those two rules protect is the projects index, and the builds behind it are the ten concept sites.
hero-swipe-lock.js:34-41
What reduce must not switch off
The test is not does it move. A scroll lock moves nothing, and turning it off under reduce would break a navigation affordance the visitor asked for explicitly; a pin moves nothing by the time the preference is read, and leaving it in place strands the reader. Drawing that line per mechanism, inside one component if that is where the seam falls, is most of the work.
- a scroll lock
- Not a motion system, so reduce does not switch it off — locking scroll is a navigation affordance and the visitor asked for it explicitly. The two label swaps beside it are motion, and they collapse to an instant swap. The lock itself is reference-counted by the scroll owner, so this control and the menu can each hold one without releasing the other's.
- a pin
- Layout, so reduce has to undo it rather than stop animating it. The committed accessibility check ports an assertion the old site never ran: under reduce, with the menu open, 1,200 px of wheel, an End press and a PageDown press all leave the scroll position unchanged, and Escape releases it and lets the page reach its own bottom.
- smooth scrolling
- A different mode rather than a disabled one. On the site this one replaced, the obvious call — stopping the smoother — was measured to leave the page completely unscrollable: 0 px from the wheel, 0 px from End. That is why its patch polled up to 600 frames for a global and destroyed the instance instead. Here the smoother is simply never constructed under reduce, and scrolling is native and complete.
- a pause control
- Absent rather than inert. The credential deck deals six cards on a 2,200 ms beat with a 600 ms deal, a 13.2 s turn, and under reduce it never stages at all — so the button that would stop it is removed rather than disabled. A control that is present and does nothing is worse than one that is not there: it takes a tab stop, an accessible name and a press.
- a colour crossfade
- The one effect on this site that declares runs. Nothing translates, rotates or scales, and the argument is measured rather than asserted — with the effect switched off at 390 px the chapter's own travel went to zero and the page held one flat colour for its whole length. Reduced motion is on by default for a large share of phones, which is this repository's working assumption rather than a figure it has measured.
The scroll case has a version in the library too, and it moved. The version pinned here, 1.1.20, contains no reference to the query at all — so never constructing it was the only answer available. Current Lenis ships respectReducedMotion on by default and takes a third position: keep the instance running with its interpolation forced to one-to-one, so that WebGL and DOM synchronisation survives, and make programmatic scrolls instant. Three defensible answers to one preference, which is the taxonomy argument in miniature.
Whether a smoother is worth having at all is a separate measurement, and it is Lenis against native scroll, measured.
The instant-jump answer for anchors has its own consequences for focus and history — what a smooth anchor link breaks.
axe-core 4.11.0 · 144 scans · 2026-08-24
How you prove it, and the 707 that became 5
The whole-page claim is two assertions made in the same pass, and they are the thesis in executable form. With the preference set, no subsystem holds a frame — the frame clock's live subscriber count is zero, the hero is not subscribed, no Rive instance is running, the particle runtime is parked. And every 2-D surface still has a settled image on it: no canvas among the seven comes back with zero painted pixels.
The live round trip is the harder half, because a preference can change mid-session. Turning reduce on tears down 26 of the 41 ScrollTriggers and every pin spacer with them, which is where the 3,203 px of document height goes; turning it off rebuilds them, with no effect leaked or duplicated across the trip, and nothing left clipped in either direction. The scroll offset does not survive it — 9,645 px, then 8,004, then 9,716 — and that 71 px of drift moves three more below-fold reveals into the not-yet-triggered set.
| Roster | Checks in it | Naming the preference |
|---|---|---|
| certificate | 123 | 7 |
| webgl | 72 | 5 |
| projects | 52 | 6 |
| rive | 48 | 3 |
| particles | 38 | 5 |
| rest | 20 | 7 |
| accessibility | 20 | 9 |
| all 17 rosters | 817 | 61 |
That is not a rhetorical claim. This site's published axe-core figure — 5 violations, axe-core 4.11.0, 72 documents at two viewports for 144 scans, measured on 2026-08-24 — carries a condition, and the condition is load-bearing: prefers-reduced-motion: reduce, after scrolling each document end to end. Run without it, the same instrument on the same site reported 707 serious colour-contrast nodes across 60 routes. The two sweeps cover different route counts, so they are the same tool before and after a corrected condition rather than a controlled comparison.
Every one of those 707 was an element caught mid-reveal. Axe composites a partially transparent element against what is behind it, so a colour reads against a near-identical colour and a heading fading in measures 1.02:1. The correction did not excuse anything: two real defects survived it and were fixed — a case-study card printing at 4.36:1 across 612 nodes and all 33 of its documents, and a 404 watermark at 1.25:1 — and the remaining 5 are one element on the certificate's paper, at 1.33:1 where 3:1 applies, allowed by name and by selector across three routes and two widths.
The site this one replaced is the counter-example for all of it, and its numbers are forensics of that old build rather than of this one. A grep for the query across its engine returns zero. Under reduce its GL tick ran 51 times in three seconds against 52 without, its global GSAP timeline stayed at a time scale of 1 across all 836 of its reveals, its 61 ScrollTriggers ran unchanged, and exactly one kind of Rive canvas paused — the nav hamburger, 70 of them site-wide — while 520 others kept playing. The handling that did exist was a 106-line patch that polled for a global and 24 stylesheet blocks in two incompatible policies. It is not that nobody tried. It is that the preference was answered where it was easy to answer and left unanswered where the motion actually was.
The contrast half of that story is a whole piece on its own — why an axe sweep of an animated site is mostly false positives.