Building an In-App Motion Preference Toggle

Part of prefers-reduced-motion Architecture in Accessible Motion Architecture.

The problem

A user finds one product’s animations uncomfortable but does not want to turn off motion across their entire operating system, where it also removes animations they rely on elsewhere. Another user is on a shared or managed device and cannot change system settings at all. A third has reduced motion enabled system-wide but wants the full experience on this particular site.

The OS preference is the right default and an incomplete answer. An in-app control covers the rest — provided it does not override the system preference by accident.

Root cause analysis: two sources, one decision

There are two inputs: the system preference, exposed as prefers-reduced-motion, and the user’s choice within the product. The control needs three states, not two:

  • System (default) — follow prefers-reduced-motion.
  • Reduced — reduce motion regardless of the system setting.
  • Full — allow motion even if the system says reduce.

A two-state toggle cannot express “follow the system”, so it either ignores the OS preference or silently overrides it for users who never touch the control — both of which are worse than having no toggle.

CSS must combine the two. With the choice stored in a data-motion attribute on the root element, rules need to match “the attribute says reduced” or “the attribute says system and the media query matches”. Writing that once, in a single place, is what keeps components from having to know about the toggle at all — they keep using the motion tokens, which the combined rule neutralises.

The value must be applied before first paint. Reading storage in a deferred script means the page renders with the wrong setting first, and an entrance animation may run before the preference is applied. A small inline script in the head avoids that, the same technique used for themes.

Full means full, deliberately. Allowing a user to opt into motion when their system says reduce is legitimate — it is their explicit choice in this product — but it should never be the default and should be easy to reverse.

Every combination of system setting and in-app choiceSystem is the default and the only state that follows the OS.Every combination of system setting and in-app choiceSystem: no-preferenceSystem: reduceChoice: system(default)Full motionReduced motionChoice: reducedReduced motionReduced motionChoice: fullFull motionFull motion (explicit opt-in)
System is the default and the only state that follows the OS.

Step-by-step resolution

Adding the controlApply before paint, combine in one CSS block, expose clearly.Adding the control1Add an inline head script that reads storage and sets data-motion.Correct from the first frame2Write one CSS block that neutralises motion tokens for both sources.Components need no changes3Render a radio group with system, reduced and full.Three states, clearly labelled4Persist the choice and update the attribute on change.Applies immediately5Listen for system preference changes for the system state.Stays correct without reload6Test all six combinations.
Apply before paint, combine in one CSS block, expose clearly.

Production code pattern

<!-- In <head>, before stylesheets are applied to content. -->
<script>
  try {
    const stored = localStorage.getItem('motion') || 'system';
    document.documentElement.dataset.motion = stored;
  } catch { document.documentElement.dataset.motion = 'system'; }
</script>

<fieldset class="motion-setting">
  <legend>Motion</legend>
  <label><input type="radio" name="motion" value="system" checked> Use my device setting</label>
  <label><input type="radio" name="motion" value="reduced"> Reduce motion</label>
  <label><input type="radio" name="motion" value="full"> Full motion</label>
  <p class="hint">Affects animations such as page transitions, menus and decorative movement.</p>
</fieldset>
/* One place decides what "reduced" means; components just use the tokens.
   Two rules cover the two sources: the explicit choice, and the system
   preference while the choice is "system". */
@media (prefers-reduced-motion: reduce) {
  :root[data-motion="system"] {
    --motion-enter-distance: 0px;
    --motion-enter-duration: var(--duration-100);
    --motion-exit-duration: var(--duration-100);
    --motion-move-duration: 0ms;
    --motion-stagger-step: 0ms;
  }
}
:root[data-motion="reduced"] {
  --motion-enter-distance: 0px;
  --motion-enter-duration: var(--duration-100);
  --motion-exit-duration: var(--duration-100);
  --motion-move-duration: 0ms;
  --motion-stagger-step: 0ms;
}

/* Decorative, non-token motion is switched off the same way. */
:root[data-motion="reduced"] .parallax,
:root[data-motion="reduced"] .ambient { animation: none; }
@media (prefers-reduced-motion: reduce) {
  :root[data-motion="system"] .parallax,
  :root[data-motion="system"] .ambient { animation: none; }
}
const root = document.documentElement;
const system = matchMedia('(prefers-reduced-motion: reduce)');

document.querySelectorAll('input[name="motion"]').forEach((input) => {
  input.checked = input.value === (root.dataset.motion || 'system');
  input.addEventListener('change', () => {
    root.dataset.motion = input.value;
    try { localStorage.setItem('motion', input.value); } catch {}
  });
});

// Keeps the "system" state correct if the OS preference changes while the page is open.
system.addEventListener('change', () => {
  if (root.dataset.motion === 'system') root.dataset.motion = 'system';   // re-evaluate rules
});

// Script animations read the effective preference, not the media query alone.
export const motionReduced = () =>
  root.dataset.motion === 'reduced' || (root.dataset.motion !== 'full' && system.matches);

Rendering Impact: none beyond one style recalculation when the attribute changes. Because the decision is centralised in token overrides, switching the preference re-styles the page once rather than touching every component.

Exporting an motionReduced() helper matters as much as the CSS: script animations that check matchMedia directly would ignore the in-app choice, which is the most common way these toggles end up half-working.

Components needing changes to support the toggleDesign system with 42 components; token-based motion means the toggle is a one-place change.Components needing changes to support the togglePer-component reduced-motion queries42 componentsToken-based motion1 componentsPlus non-token decorative effects4 components
Design system with 42 components; token-based motion means the toggle is a one-place change.

Presenting the setting

Put the control where users look for accessibility or appearance settings, next to theme and text size, not buried in an advanced panel. Label the states in plain language — “Use my device setting”, “Reduce motion”, “Full motion” — and describe what changes, because “motion” is vague: users want to know whether it affects page transitions, videos or loading indicators.

A radio group is the right control because the three states are mutually exclusive and none is an on/off of the others. A switch labelled “Reduce motion” cannot represent “follow my device”, and defaulting it to off silently overrides the system preference for everyone who never opens settings.

Verification checklist

Constraints and trade-offs

  • A three-state control is slightly more complex than a switch, and worth it.
  • Storage may be unavailable; fall back to the system preference.
  • An inline head script is render-blocking, so keep it tiny.
  • Allowing “full motion” against a system preference is an explicit opt-in that should stay easy to undo.
  • Token-based motion is what makes this cheap; products without tokens need per-component work.

Frequently asked questions

Why not a simple on/off toggle for reduced motion?

Two states cannot express “follow my device”, so the control either ignores the system preference or overrides it for users who never touch it.

Should an in-app setting override the operating system?

Only when the user explicitly chooses it. The default must follow the system preference.

How do I stop a flash of animation before the preference is applied?

Set the attribute from a small inline script in the head, before content is rendered.

Do script animations need changes too?

Yes. They must read the effective preference — the combination of stored choice and system setting — rather than the media query alone.