Reducing Motion Preferences in CSS: How the Setting Becomes a Media Feature

You wrote a @media (prefers-reduced-motion: reduce) block, the animation still runs, and nothing in DevTools looks wrong. The problem is almost never the media query syntax — it is a misunderstanding of what the feature actually reports, when it is evaluated, and how much weight the block carries in the cascade. This page is about the plumbing: how an operating-system checkbox becomes a value your stylesheet can test, what the two values mean and do not mean, and the cascade and evaluation rules that decide whether your override lands. It belongs to Accessibility in CSS Animations, and it is the mechanical foundation the other motion pages here build on.

What this page settles:

  • Where the value comes from on each platform
  • Why no-preference is the better gate than reduce
  • How the query re-evaluates without a reload
  • Why a media query never adds specificity

The chain from setting to stylesheet

prefers-reduced-motion is a user preference media feature, the same family as prefers-color-scheme and forced-colors. It is not something the page sets, not something the browser infers from device performance, and not a signal about network conditions. It reports one thing: whether the user has indicated to their system that they prefer minimised motion.

Each platform surfaces that indication under its own name. macOS and iOS call it Reduce Motion under Accessibility → Display. Windows calls it Show animations in Windows (inverted — turning it off requests reduction) under Settings → Accessibility → Visual effects. Android has Remove animations under Accessibility. GNOME exposes Enable animations. The browser reads the platform value and exposes it, unchanged, as the media feature. Some browsers additionally derive reduce from their own settings, but the author-facing contract is identical everywhere.

From system setting to media feature value A platform accessibility setting is read by the browser, exposed as a media feature, and resolves to one of two values that select a CSS branch. One setting, one feature, two values OS motion setting macOS / Windows / iOS Browser exposes it prefers-reduced-motion Query evaluated live, no reload no-preference motion allowed reduce motion suppressed An engine with no support for the feature matches neither branch.

Two values, and the third one that does not exist

The feature accepts exactly no-preference and reduce. There is deliberately no unknown, no full, and no way to distinguish "the user chose to allow motion" from "this platform has no such setting". That absence is a design decision, not an oversight: the specification treats the default state and an explicit opt-in to motion as the same thing, because neither is a request for reduction.

It also has a boolean context. Written without a value, @media (prefers-reduced-motion) is shorthand for the value is not no-preference — which today means it matches reduce and nothing else. It is legal and concise, but the explicit form documents intent better in a shared codebase, and it survives any future value being added to the feature.

The trap is negation. These three queries look interchangeable and are not:

/* A: matches only when the user asked for reduction. */
@media (prefers-reduced-motion: reduce) { }

/* B: matches only when the user did NOT ask for reduction. */
@media (prefers-reduced-motion: no-preference) { }

/* C: matches when the value is anything other than no-preference,
      which on an engine with no support for the feature is nothing. */
@media not all and (prefers-reduced-motion: no-preference) { }

On every browser shipping in the last several years A and C behave the same. On an engine that never implemented the feature, an unsupported feature name makes the whole query fail to match — so A matches nothing (safe: the user gets your defaults) and C, because of how not interacts with an unknown feature, also matches nothing. B is the interesting one: it also fails to match, which means motion you placed inside B never runs on that engine. That is the argument for treating B as the gate.

Approach rationale: gate motion in, do not strip it out

Two architectures are possible, and they are not equivalent.

Subtractive — write the animated interface as the default, then remove motion inside a reduce block. This is what most codebases do, and it is what the copy-paste blocks in the prefers-reduced-motion recipes are shaped for. Its weakness is that it fails open: every new animation someone adds is live for motion-sensitive users until a reviewer notices the missing override. The default is the unsafe state.

Additive — write the static interface as the default, then add motion inside a no-preference block. This fails closed. A developer who adds an animation outside the gate gets a static-looking component in their own reduced-motion test, which is a visible bug rather than a silent accessibility regression. The cost is that every animated rule lives one level of nesting deeper, and third-party CSS you do not control still needs a subtractive net.

For a design system being built from scratch, additive is the stronger default. For an existing codebase, the honest answer is both: additive for new components, plus one subtractive block as a backstop for everything you inherited. Neither approach can help with motion driven by JavaScript or by a media element, which is why the equivalent check exists in script — but the CSS decision comes first, because it is the one that applies without any runtime cost.

Complete working implementation

A card that lifts and cross-fades on hover, written additively. The static presentation is the base layer; every motion-bearing declaration sits inside the no-preference gate, so a browser that cannot answer the question renders the static version.

<article class="card">
  <h3 class="card__title">Quarterly report</h3>
  <p class="card__body">Revenue, retention, and headcount for Q2.</p>
  <a class="card__link" href="/reports/q2/">Open report</a>
</article>
/* --- Layer 1: the static interface. No transitions, no transforms.
   Every state change below is still expressed here, just instantly. */
.card {
  --lift: 0px;
  --ring: transparent;

  padding: 1.25rem;
  border: 1px solid #94a3b8;
  border-radius: 0.5rem;
  background: #ffffff;
  /* translate takes a custom property, so the motion path can change the
     value without ever rewriting this declaration. */
  translate: 0 var(--lift);
  box-shadow: 0 0 0 3px var(--ring);
}

.card:hover,
.card:focus-within {
  --lift: -4px;          /* still applies with no preference set or reduce */
  --ring: #005fcc;
}

/* --- Layer 2: motion, gated. This block is only entered when the user has
   NOT asked for reduction. An engine that does not know the feature never
   enters it, so it also gets the instant version above. */
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition:
      translate 200ms ease-out,
      box-shadow 200ms ease-out;
  }

  /* Entrance animation lives here too, not in the base layer. */
  .card__title {
    animation: title-in 350ms ease-out both;
  }

  @keyframes title-in {
    from { opacity: 0; translate: 0 6px; }
    to   { opacity: 1; translate: 0 0; }
  }
}

/* --- Layer 3: the backstop, for CSS you did not write. Scoped to a
   container so it cannot fight your own rules, and near-instant rather
   than 0s so animation-fill-mode: forwards still resolves cleanly. */
@media (prefers-reduced-motion: reduce) {
  .vendor-widget *,
  .vendor-widget *::before,
  .vendor-widget *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

The .card element ends up with identical appearance in both branches — the lift, the ring, and the final title position are all in the base layer. What the gate controls is purely whether the change is interpolated or immediate.

The technique that makes it work

The load-bearing move is putting the state change in a custom property and the timing in the gate. --lift: -4px is set by :hover unconditionally; transition: translate 200ms is set only inside no-preference. Because a transition declaration that never applies simply means the property jumps to its new computed value, the reduced path needs no override at all — there is nothing to undo. Compare that with the common structure where transform: translateY(-4px) sits in the hover rule and a reduce block has to reach back in and write transform: none, duplicating knowledge of what the hover state does in two places that can drift apart.

The same asymmetry applies to animation. A keyframe declaration inside the gate is absent under reduce, which means both fill-mode never applies and the element renders at its natural resting styles — exactly the end state the animation was heading toward. You get the correct static result for free, provided your to frame matches the element's unanimated appearance. Write the keyframes as a departure from the resting state rather than an arrival at it, and the reduced path is always correct by construction.

Variation: reading the same preference from script

CSS is the right place for the decision, but some motion is not CSS's to control — a canvas loop, a smooth-scroll library, an autoplaying video. matchMedia exposes the identical feature to script, and crucially the result is live:

const query = window.matchMedia('(prefers-reduced-motion: reduce)');

function applyMotionPreference() {
  document.documentElement.toggleAttribute('data-reduced-motion', query.matches);
}

applyMotionPreference();
// The user can toggle the OS setting while the page is open.
query.addEventListener('change', applyMotionPreference);

Mirroring the value onto an attribute lets stylesheet rules that cannot be written as a media query — say, a rule that must also consider a container's state — read the preference through an attribute selector. Do not let the attribute become the only source of truth: keep the media query as the primary gate so the page is correct before script runs, and treat the attribute as an extra hook.

The change listener matters more than it looks. Because the media feature is live, a user who enables Reduce Motion mid-session gets restyled CSS immediately but keeps a running JavaScript animation loop unless you listen. That mismatch — CSS calm, canvas still moving — is a common bug in dashboards that outlive a single interaction.

Browser support

prefers-reduced-motion has been supported since Safari 10.1, Firefox 63, Chrome 74 and Edge 79, so every evergreen engine answers the query. matchMedia with addEventListener on the result is available across current engines — for much older Safari builds the legacy addListener method is the fallback. No @supports guard is possible or needed, because an engine that does not recognise the feature simply fails to match either branch, which is precisely the behaviour the additive architecture above relies on.

FAQ

What is the difference between reduce and no-preference? They are the two values of the same media feature. reduce means the user has asked their operating system for less motion; no-preference means they have not. There is no third value and no way to detect that the platform lacks the setting entirely.

Why does a not-reduce query behave differently from no-preference? On a browser that does not support the feature at all, both queries fail to match, but they fail for opposite reasons. Querying no-preference is the safer gate because an unsupporting engine then falls through to your static styles rather than to a motion path nobody asked for.

Does the media query re-evaluate when the user changes the setting? Yes. It is a live media feature, so toggling the operating-system preference restyles the open page immediately without a reload. Running animations are re-resolved against the new computed values on the next style recalculation.

Where should the reduced-motion block sit in my stylesheet? A media query adds no specificity, so a reduce block only wins over an equally specific rule if it comes later in source order. Put motion overrides after the rules they modify, or raise specificity deliberately rather than reaching for !important.

Related articles

More pages in the same section.