Build a Reduced-Motion Web Treatment Without Losing Its Explanation
Build a Reduced-Motion Web Treatment Without Losing Its Explanation
A reduced-motion route is not the ordinary route with the animation switched off. If you stop a two-second sequence at an arbitrary frame, you get a still of a moment that may not even be one of the states the sequence was explaining. A reader who asked for less motion has not asked for less explanation. They have asked not to be moved around while they read it.
The workable separation is this: decide which step of the explanation is currently selected, commit that as ordinary application state, and treat motion as one of several ways to present it. When motion is removed, or when the preference flips while motion is mid-flight, the selected step, its text, and its controls keep working. What changes is the presentation, not the argument. The rest of this article is about making that separation hold, including the awkward case where the preference changes between two frames of a running transition.
Classify motion by the meaning it carries
Before you write a preference check, walk the treatment and name what each moving piece is doing. Three kinds are worth separating, because they fail differently when motion is removed.
Decorative movement carries no information the reader needs. An ambient turntable spin, a slow drift behind a headline, a pulsing accent on an idle button. None of it explains anything, and dropping it in the reduced route costs the reader nothing. Often it improves the ordinary route too.
Navigation motion moves the reader between sections — scroll-linked reveals, animated anchor jumps, a transition that swaps one panel for the next. The trap here is that the motion sometimes is the navigation: if the next section only becomes visible because a transition completed, removing the transition hides content. Navigation motion usually needs a fallback rather than a deletion: an instant jump, or ordinary document flow that would have worked before anyone added the effect.
Explanatory motion shows a comparison, a direction, a sequence, or a state change. This is the kind you cannot simply remove, because the motion is carrying the point. It has to be replaced. Three replacements do most of the work:
- A labeled sequence — a static strip of states in order, each labeled. This preserves the order and each state's appearance. It does not preserve timing or simultaneity.
- A direct state change — the reader moves state to state by action, and each state appears instantly. This preserves the reader's control over which state is shown.
- Deliberate user-controlled playback — a visible Play control for the original motion. This keeps the animation available to readers who want it without imposing it on readers who do not.
One substitution consistently fails. A single poster frame is not a reduced route. Freeze a cutaway animation on an intermediate pose and you may get a shell that is half-open with no way to tell whether it started closed or is on its way shut. The frame has omitted the very change the treatment existed to communicate.
A caveat that belongs beside this: a media query and a listener are implementation technique, not a conformance verdict. Suppressing CSS animation does not by itself establish that the scripted state, the controls, or the whole treatment remain usable. The mechanism and the guarantee are separate claims.
Make explanatory state independent of animation
The failure mode to design out is subtle and common. The transition starts, and the code waits for the transition to finish before it writes the new state:
goTo(step) {
startTransition(step, () => {
currentStep = step; // committed only when the tween ends
renderText(step);
enableControls();
});
}
This is fine until the transition is canceled. Cancel it — because the reader asked for less motion, because they clicked twice, because the tab was hidden — and the completion callback never fires. The state never lands. The text still describes the previous step, the Next button sits disabled waiting for a completion that will not occur, and if you were rendering from currentStep you are now showing the wrong drawing with the right label, or the right drawing with the wrong one.
Commit the step at the moment of the reader's action, and let the tween be a presentational layer on top of an already-correct state. Two consequences follow. First, stopping motion cannot leave half-revealed content, because there is nothing half-revealed to stop: the text, the controls, and the static drawing were final before the first frame of the tween. Second, a completion callback is never the only path to a committed state. It can clean up its own bookkeeping, and that is all it needs to do.
A concrete illustration, which I am proposing rather than reporting — no page, listener, or browser test has been built for this piece. A three-step product cutaway of a fictional clamp housing:
- Closed. Latch engaged, shell halves together, spring under load.
- Releasing. Latch rotating out, spring partly extended, shell halves just beginning to separate.
- Open. Latch clear, spring fully extended, shell halves apart.
Each step has an explanation panel and a drawing. The controls are Previous, Next, and Replay; Next is unavailable on step 3, where there is no next, and Replay replays the current step's tween without changing which step is selected. In the ordinary route, moving between steps runs a 600 ms tween that rotates the latch, extends the spring, and separates the shells while the panel cross-fades. In the reduced route, moving between steps swaps the drawing and the text immediately, with a labeled strip alongside showing closed → releasing → open so the order is visible without playback, and Replay remains available as an explicit opt-in to the original motion.
The two routes are two presentations of the same explanation. They are not a complete edition and an impoverished one. If the reduced reader can still name the three states, understand what triggers the transition from one to the next, and control which is showing, the explanation has survived. If they can only see one frozen pose, it has not.
Apply the initial preference and respond when it changes
Two mechanisms cover the two halves of the problem. For motion you wrote in CSS, the preference query (prefers-reduced-motion: reduce) suppresses the animation. The W3C technique for this — C39, in the WCAG 2.2 techniques set, last checked here in mid-September 2026 — documents preference-based suppression together with a static-first alternative. That is a bounded CSS technique; it establishes how to stop CSS motion, not that your scripted treatment is correct or that anything about it is clinically safe. For behavior you drive in JavaScript, matchMedia gives you the same query as a state you can read, and the MediaQueryList change event fires when the match flips. MDN's reference for that event was last checked here in mid-September 2026 and documents reacting to the change through a listener and the object's matches value. That is API documentation. It tells you the event arrives; it does not tell you the alternate state is right.
The shape that holds up:
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let currentStep = 1;
let activeTween = null;
// Cancelling clears the record, so activeTween names live motion or nothing.
function stopTween() {
if (activeTween) activeTween.cancel();
activeTween = null;
}
// Only the tween that is still current may clear itself.
function startTween(step) {
const handle = playTween(step, () => {
if (activeTween === handle) activeTween = null;
});
activeTween = handle;
}
// The reader's action commits the step synchronously.
function goTo(step) {
currentStep = step;
renderStep(step); // panel text, static drawing, strip highlight
updateControls(step); // Previous / Next / Replay availability
stopTween(); // a second activation replaces the first, it does not stack on it
if (!motionQuery.matches) {
startTween(step);
}
}
// Replay is an explicit reader action, so it bypasses the preference guard.
function replay() {
stopTween();
startTween(currentStep); // presentation only: the committed step does not change
}
// The preference flips while a tween may be running.
motionQuery.addEventListener('change', (event) => {
if (event.matches) {
stopTween();
renderStep(currentStep); // snap the committed step to its static form
}
// Turning the preference off does not replay anything on its own.
});
This is a sketch, not a tested implementation, and renderStep is assumed idempotent so it can be called again safely. The parts that matter are the order of operations and what each branch refuses to do.
goTo sets currentStep first and renders from it. By the time the tween exists, the step is already committed. If the tween is canceled one frame in, the static form of the step is already what is on screen.
goTo also stops whatever was running before it starts anything new, so a reader who activates Next twice in quick succession replaces the first tween instead of leaving it running underneath the second. And the completion callback clears activeTween only if the handle it belongs to is still the current one, so a stale callback from a superseded tween cannot null out the live one. Those two rules together keep activeTween an accurate record of whether motion is running — which is precisely what the change handler's guard needs, since a guard that finds null while a tween is actually in flight will let the motion continue past a preference change.
Replay does not travel through goTo's guard. It is its own entry point, and it is the same action in both routes: it plays the tween for the step already committed and changes nothing else. Running it under reduce does not contradict the suppression rule, because the rule is about motion the reader did not ask for, and Replay is motion the reader did ask for. What it must not do is commit a step, reassign currentStep, or alter the panel text, the settled drawing, or the control availability. It is playback, not navigation.
The change handler reads currentStep and settles to it. It does not reset to step 1. It does not move focus. It does not disable and re-enable controls, which would be a state change the reader never asked for. "Settle" means snap the drawing to the committed step's static representation, not freeze it between two poses. Freezing mid-tween is the intermediate-frame failure again, dressed up as a preference handler.
The handler deliberately does nothing when the preference turns off. Flipping back to no-preference is not an instruction to replay the cutaway, and if the code started a tween on every change event it would restart an animation the reader cancelled, on a system toggle they may not have meant as a command. New playback should follow a reader action — Next, Previous, Replay — and only that. Replay can stay visible in the reduced route as an explicit opt-in to the original motion for readers who want it. The preference says do not move without consent, not never move.
Now run the central case through it. The reader is on step 1 and activates Next. currentStep becomes 2 and the panel shows the releasing explanation before the tween starts. At about 250 ms into the 600 ms transition, the operating system flips to reduce. The handler cancels the tween and calls renderStep(2), so the drawing snaps to the releasing state and the strip highlights releasing. The panel still reads releasing. Next is still enabled, because step 2 of 3 has a next; Previous is still enabled. Focus stays on the Next button, which is where the reader's hand already was. Nothing resets to closed. Nothing hangs between two frames. The reader's place in the argument is exactly where they left it.
Two more cases worth walking. If the reader is already on step 3 and the preference flips, the handler settles step 3 and touches nothing else — no jump back to closed, no re-render of a step the reader has not moved away from. And if the control the reader was on becomes unavailable across a step change — Next disabling itself on step 3, where there is no next — move focus to a named target, such as the drawing's heading, rather than letting it fall to the document body, and do that only on the step change that disabled the control, not on the preference change itself. A preference toggle is not a reason to relocate the reader.
Test continuity across both routes
The honest limit first: no browser test, listener check, or user evaluation of this treatment has been run for this article, and CSS suppression alone cannot prove the scripted state behaves correctly. Every check below is a proposed one.
Load the treatment twice — once with no-preference, once with reduce — and walk all three steps in both. For each step, confirm four things agree: the selected step number, the panel text, the drawing, and the availability of Previous and Next. If the panel says releasing while the drawing shows a closed shell, one of the two routes is writing state the other does not.
Then test the transitions that do not happen on a clean load:
- Toggle the preference during a running transition and confirm the selected step survives, the drawing settles to that step, and focus stays on the activated control.
- Select a later step, then flip the preference, and confirm the later step stays selected rather than resetting to the first.
- Flip the preference back and confirm nothing replays on its own.
- In the reduced route, activate Replay and confirm the original motion starts and runs to completion, with the selected step, the panel text, the settled drawing, and the strip highlight unchanged before, during, and after it.
- Activate Next twice in quick succession with no preference set, then flip to reduce while the second tween is running, and confirm the motion stops. If the first tween is still moving, or if the second keeps going after the flip, the in-flight tween was not being replaced and cleared.
- Drive the whole treatment by keyboard with motion suppressed, and confirm you can reach every step and every control without the Next button spending time disabled between states.
Watch specifically for the callback dependency. If disabling the animation in the developer tools leaves the text or the controls stuck, the commit is happening on transition completion and the mid-use case will break the same way.
The comparison to keep in front of you is what a reader can understand and do in each route. If the reduced reader has the same three states, the same explanation of what moves them from one to the next, and the same control over where they are, you have removed unnecessary movement without removing their agency. If they have a still and a disabled button, you have replaced an explanation with a photograph of one.
The finish line is simple to state and easy to miss: the same explanatory choice remains available before and after a preference change. The successful reduced route drops what carries no meaning and keeps everything that does, including the reader's place in the sequence — and it keeps that place even when the preference arrives halfway through, with the animation already running.
Frequently asked questions
What is the central separation in a reduced-motion web treatment?
Decide which step of the explanation is currently selected, commit that as ordinary application state, and treat motion as one of several ways to present it. When motion is removed, or when the preference flips while motion is mid-flight, the selected step, its text, and its controls keep working. What changes is the presentation, not the argument.
How should decorative, navigation, and explanatory motion be handled differently?
Decorative movement carries no information the reader needs and can be dropped. Navigation motion moves the reader between sections, and if the motion is what makes content visible, removing it can hide content, so it usually needs a fallback such as an instant jump or ordinary document flow. Explanatory motion shows a comparison, direction, sequence, or state change, so it has to be replaced, often with a labeled sequence, direct state change, or deliberate user-controlled playback. A single poster frame is not a reduced route.
When should a step change be committed during an animated transition?
Commit the step at the moment of the reader's action, and let the tween be a presentational layer on top of an already-correct state. If the code waits for the transition to finish before writing the new state, cancellation, double activation, or a hidden tab can leave the state unlanded, with the wrong text or drawing and controls left disabled. The article's sketch sets currentStep first, renders from it, then starts a tween only if reduced motion is not matched.
What should happen if the reduced-motion preference flips while a tween is running?
The change handler should cancel the tween and settle to the already committed step's static form. It should not reset to step 1, move focus, or disable and re-enable controls. Turning the preference off should not replay anything on its own; new playback follows a reader action such as Next, Previous, or Replay. Replay can remain visible in the reduced route as an explicit opt-in and bypasses the preference guard because it is playback, not navigation.
What checks would show that the reduced route preserved the explanation?
Load the treatment twice, once with no-preference and once with reduce, and walk all three steps in both. For each step, confirm the selected step number, panel text, drawing, and Previous and Next availability agree. Then test a preference toggle during a running transition, a later step followed by a preference flip, flipping back, Replay in the reduced route, double activation of Next followed by a flip, and keyboard use with motion suppressed. Watch for callback dependency: if disabling animation in developer tools leaves text or controls stuck, the commit is happening on transition completion.