Skip to the article

Motion Published Reading time 10 min

What reduced motion should do to a WebGL or canvas hero

The CSS blanket everybody ships decides how long an animation runs. It cannot decide which frame it stops on, what layout it leaves behind, or what a canvas holds once its loop is gone — and on a hero drawn by Three.js, Rive or a 2-D context, those three are the whole question.

In short

Under prefers-reduced-motion: reduce, a WebGL or canvas hero should hold one whole settled frame and no frame subscription: nothing moves, and nothing is blank. The popular CSS blanket cannot produce that state, because animation-duration: 0.01ms decides only how long an animation runs — not which keyframe it stops on, not what a pinned layout does, and nothing at all about pixels a JavaScript runtime draws. On this site every animated subsystem declares one of four answers — off, end-state, shortened, or runs with a written justification — and 61 committed gate checks across 17 rosters assert both halves at once: no frame subscriber held anywhere on the page, and every one of the seven canvases still carrying a settled image.

What to take away

  1. The blanket answers one of the four questions the preference asks: animation-duration: 0.01ms decides how long, never which frame you land on, what layout you are left in, or what a canvas holds when its loop stops.
  2. The two failure modes are symmetric, so assert both halves in the same check — no frame subscriber held anywhere, and no surface left without an image on it.
  3. Duration is the wrong lever on a keyframed animation: collapsing an 18-second wireframe cycle to 0.01ms lands every stroke on the erased keyframe, and only animation-name: none defeats it.
  4. A settled state is something you build rather than something you get — this site's hero frame was silently half-rendered until two captures at pinned times of 1.5 s and 9 s came back byte identical.
  5. A pin is layout rather than motion, so reduce has to undo it: switching off the journey rail's scrubbed transform on its own clipped three of its four chapters off the side of the document.
  6. Reduced motion is the only honest state in which to audit an animated site, because the same axe-core sweep reported 707 serious contrast nodes without the preference and 5 with it.

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
Counted over the committed rosters in tests/golden/expectations and a grep across src. The 61 and the 817 are check names in the tracked tree, not the output of a run.

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.

The blanket, and the reason it lives where it does. Both blocks are transcribed from the repository.
/* 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.

The escape, and the pose it settles on. The erased keyframe is what the blanket would have chosen on its own.
/* 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.

The vocabulary every effect on this site is registered with, and what each answer means in the shipped code rather than in the plan.
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.

Read from the shipped source rather than from the specification documents — two of which still describe an older, blanker answer for Rive that would have left five empty canvases.
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
How one settled frame is produced, and how the version that only appeared to work was caught.

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.

no preference 18,709 px
under reduce, as it ships 15,506 px
under reduce, before the layout rule 14,884 px
Home page document height at 1440 by 900, from the round-trip check in tools/golden/rest.mjs and the note in home-journey.css.Bars are to scale against the animated document.

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.
Five mechanisms, and the reasoning that decides each one. Three of the five sit inside components whose other half does answer the preference.

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.

Committed check names under tests/golden/expectations, counted with a pass over the rosters. These are assertions in the tracked tree, not the output of a run.
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.

Questions

Does prefers-reduced-motion mean no animation at all?

No. Media Queries Level 5 defines it as detecting a desire for less motion on the page, and MDN describes the author's job as an interface that removes, reduces or replaces motion-based animations — three options rather than one. WebKit's own introduction of the feature in 2017 recommended serving an alternate, simpler animation, or another visual indicator that conveys the intended meaning. On this site that becomes four declared answers per effect, and the one that runs unchanged had to be justified in writing.

Should a WebGL hero be removed entirely under reduced motion?

No, and that is the second failure mode rather than the safe option. Under reduce this site's hero renders one complete frame and then stops: the portrait, the halftone and the docked pose are present, the pointer parallax and the fluid wake are not, and no frame subscription is held. The committed checks assert that more than half the canvas is lit, that the frame is a whole frame — a pinned 3-second time has to reach the shader's own uniform, because a half-frame leaves it at 0 with a null noise texture — that no pointer is subscribed, and that the renderer does not tick on by itself over the following 1,200 ms.

Is the 0.01ms blanket enough on its own?

It is necessary and it ships here, in the first cascade layer where its important declaration cannot be beaten. It answers one of the four questions the preference asks. It cannot decide which frame a stopped animation lands on: collapsing this site's 18-second wireframe cycle to 0.01ms lands every stroke on the erased keyframe, and only animation-name: none fixes that. It cannot reach layout, where a sticky plate still pins and a horizontal track still clips, and it cannot reach anything GSAP, Three.js, Rive or a scroll smoother draws.

Why 0.01ms rather than 0s, and why the iteration count?

0.01ms preserves the transitionend and animationend events, so JavaScript that waits for a transition before removing an element or advancing a state machine keeps working, where a true zero can skip the event and strand that logic. Setting the iteration count to 1 is required because an animation declared infinite does not stop at a 0.01ms duration — it becomes a very fast flicker, which is the opposite of the request. Both lines belong in the snippet, and neither says anything about the frame the animation stops on.

Do GSAP or Lenis handle this for me?

GSAP does not, deliberately: its documentation says respecting the preference requires intentional code, and offers gsap.matchMedia with a reduce condition whose own worked example sets the duration to zero. Lenis does now — recent versions honour the preference by default, disabling smoothing by forcing interpolation to one-to-one while keeping the instance alive so WebGL and DOM synchronisation survives. The version pinned in this repository, 1.1.20, contains no reference to the query at all, which is why the site's answer is to never construct the smoother under reduce. Check your pin before relying on the library.

Sources

Measured in this repository

  • src/runtime/webgl/core/webgl-runtime.js The hero's run predicate, the settled frame rendered at zero delta, and the note on why the drawing buffer cannot be screenshotted after the fact.
  • src/styles/base/reset.css The kill switch itself, in the first cascade layer, with the documented exception on the duration line.
  • src/runtime/rive/rive-instance.js The settle answer as contract data, and the ten fixed advances that reach it without spinning on a looping state machine.
  • tools/golden/webgl.mjs The reduce section: pin a time, render once, read pixels back, and assert both that the frame is whole and that no frame is held.
  • contracts/published-measurements.json The axe-core figure with its condition, and the note recording what the same sweep reported without the preference set.

Checked against

Alaa Abbod

Written by

Alaa Abbod

Creative Developer — Herne, Germany

Designer and developer who builds accessible websites, mobile apps, online stores and visual identities as one job, by hand. This site is published in English, German and Arabic from one source, which is where most of these questions came from.

Please rotate your device,
This is a vertical build.