Writing the reduced-motion contract before the animation

A reduced-motion block written after the animations is a block that has to catch up with them. Written first, it binds everything added later — and the hardest decision is what it must deliberately not switch off.

The usual order is: build the interface, ship it, then someone files an issue and a prefers-reduced-motion block gets added at the bottom of a stylesheet. That block is now a catalogue. It has to name every animation that already exists, and it goes stale the moment the next one lands, silently, in the direction that hurts — a new animation is unaffected by a rule that does not mention it, and nothing fails.

Reversing the order fixes that structurally. This project's motion.css was written before any production animation existed and contains none. What it contains is a set of named opt-in classes and a reduced-motion block that acts on the tokens those classes read.

.motion-reveal {
  transition:
    opacity var(--ds-duration-reveal) var(--ds-ease-out-quint),
    transform var(--ds-duration-reveal) var(--ds-ease-out-quint);
}

Motion is opt-in through a named class and a duration token, never applied blanket. So the reduced-motion block does not have to enumerate animations. It neutralises the tokens and switches off the named classes, and anything added later that uses the system is bound before it is written.

The decision that is actually hard

The reflexive implementation is the one you have seen:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

This is wrong, and it is wrong in a way that is easy to miss because it looks maximally respectful.

A person asking for less motion is not asking for an interface that stops confirming their clicks. Blanket-disabling every transition takes focus, hover and press feedback with it. The button no longer acknowledges the press. The focus ring appears with no relationship to the thing that moved focus. You have answered a request about vestibular comfort by removing the interface's ability to tell anyone what just happened — and the people most likely to have that preference set are not a group you want to hand a less legible interface to.

So the contract splits the two:

@media (prefers-reduced-motion: reduce) {
  :root {
    /* Decorative durations collapse. */
    --ds-duration-reveal: 1ms;
    --ds-duration-media: 1ms;
    --ds-stagger-step: 0ms;
    /* Control feedback is shortened, not removed. */
    --ds-duration-control: 80ms;
    --ds-duration-instant: 40ms;
  }
}

Decorative durations collapse to 1ms and the named decorative classes are switched off outright and forced to their resting state, so nothing can be left mid-animation or stuck hidden. Control-level durations drop to 80ms — fast enough that nobody would describe it as animation, slow enough that the change reads as one event rather than a repaint.

Where the line falls is a judgement, and it should be written down

"Decorative" and "control" are not properties you can read off a CSS declaration. Something has to decide, and the decision is worth recording next to the code.

The clearest borderline case here is the accent cross-fade. Changing the accent colour repaints a large part of the page over 450ms. By duration and by area it looks decorative. It is not: someone pressed a button and is owed an answer. Collapsing it to 1ms would mean the page changes colour instantly with no connection to the press — which reads as a glitch, not as a response. It is shortened to the control duration instead.

The general test that fell out of this: ask what the motion is for. If it exists because the interface is pleased with itself, it goes. If it exists because a person did something and needs to know it landed, it gets shorter and stays.

What the contract implies about component design

Written first, the contract stops being a stylesheet concern and starts constraining how components are built. Three consequences showed up repeatedly.

The resting state has to be the finished design. If reduced motion forces an element to its resting state, and the resting state is "opacity 0, waiting to be revealed", you have hidden content from the people who asked for less motion. Every reveal here therefore animates from a state to the base style, never the other way round, so switching the animation off lands on the complete composition. That is the same discipline that makes the site work without JavaScript at all.

Enhancements read the preference before they start, not after. The typed masthead mark clears its text in a layout effect — which React runs before the browser paints the hydrated frame — and checks the preference first. Under reduced motion it never clears, so the server-rendered complete text simply stays. There is no flash of the finished mark followed by a restart, because the decision happens before the first client paint rather than in an effect that runs after it.

Turning something off is not always the accessible answer. The typed mark's caret is a case in point. The blink is gated behind prefers-reduced-motion: no-preference, so reduced motion gets a steady caret rather than no caret. Removing the caret entirely would have removed a piece of the identity to satisfy a preference about movement, when the caret itself does not move. Blinking is the motion; the caret is not.

The same applies to the hero's raster field, which stops its frame loop and renders one static frame rather than disappearing. The composition is intact; only the animation is gone.

Live preferences, not startup flags

prefers-reduced-motion is a system setting a visitor can change with the page open, and pointer: fine changes when a mouse is plugged in. Reading either once at mount produces an interface that is correct only for the state it booted in.

So every gated interaction here binds and unbinds as a whole lifecycle:

const still = window.matchMedia('(prefers-reduced-motion: reduce)');
still.addEventListener('change', sync);

sync compares the wanted state against the current one and starts or stops accordingly — idempotent, so the effect cleanup can call it without knowing what state it was in. This is more code than a boolean check at mount. It is also the difference between honouring a preference and honouring the preference someone happened to have when the page loaded.

What it costs

Almost nothing, if you do it first. The contract is one media query and a set of tokens, written before there was anything to catch up with.

Doing it last costs an audit — of every animation, every reveal's resting state, and every enhancement that assumed it would run. That audit is the thing that never quite gets finished, which is the argument for the order rather than for the rule.

This is one of a family of decisions on this site where the check comes before the work rather than after it, alongside making contrast a build gate rather than a review step.

All articles