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.
Step-by-step resolution
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.
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.
Related
- prefers-reduced-motion Architecture — the parent topic
- Reduced Motion in Design Tokens — the token layer the toggle switches
- Detecting prefers-reduced-motion in JavaScript — reading and observing the system preference