Detecting prefers-reduced-motion in JavaScript
Part of prefers-reduced-motion Architecture in Accessible Motion Architecture.
The problem: the cascade cannot reach imperative motion
The CSS reduced-motion cascade suppresses declarative animations before the first paint, but a large share of modern motion is driven from JavaScript: the Web Animations API, scroll-triggered reveals wired through IntersectionObserver, and dynamic will-change allocation applied on interaction. None of these are governed by a @media query. If you rely on CSS alone, a reduced-motion user still gets the full imperative experience — the exact motion they asked the operating system to spare them. Detecting the preference in script, and gating those code paths, closes the gap.
Root cause: two motion systems, one preference
A page has two motion systems running in parallel. The declarative one — transitions and @keyframes — is resolved during the browser’s Style phase, so the prefers-reduced-motion cascade can neutralise it before anything paints. The imperative one — element.animate(), observer callbacks that add animation classes, JavaScript that writes will-change — runs on the main thread whenever your code executes, entirely outside the cascade’s reach.
window.matchMedia('(prefers-reduced-motion: reduce)') is the bridge between them. It returns a MediaQueryList whose matches boolean reflects the same OS preference the CSS media query reads, and whose change event fires when that preference flips at runtime. Reading matches before you start imperative motion lets the JavaScript system honour the identical contract the CSS system already honours. The subtlety is timing: on the server there is no window, and during hydration the first client render must not assume motion is welcome, or it will flash before your detection runs. The safe default is reduced, upgraded to full motion only once detection confirms the user has not opted out.
Step-by-step: detect, subscribe, and gate
- Read once, synchronously, before any imperative animation starts, guarding for the absence of
window/matchMediain non-browser environments. - Subscribe to the
MediaQueryListchangeevent so the app reacts when the user toggles the OS setting mid-session. - Gate each imperative path: skip Web Animations API playback, skip IntersectionObserver reveal classes, and skip will-change layer allocation when the preference is
reduce. - Default to reduced on the server and re-evaluate after hydration, so the initial client render is calm.
Production pattern: a reusable detection module
The module below centralises detection, exposes the current state, notifies subscribers on change, and demonstrates gating WAAPI, an observer, and will-change. Read the inline comments for each gating decision.
// motion.js — single source of truth for the reduced-motion preference.
// SSR guard: window/matchMedia are absent on the server. Default to REDUCED
// so the first client paint is calm; upgrade only after detection confirms.
const supportsMatchMedia =
typeof window !== 'undefined' && typeof window.matchMedia === 'function';
const query = supportsMatchMedia
? window.matchMedia('(prefers-reduced-motion: reduce)')
: null;
// prefersReduced() is the boolean every animation path must consult first.
export function prefersReduced() {
return query ? query.matches : true; // true == reduce on the server
}
// Let features react when the user flips the OS setting at runtime.
const subscribers = new Set();
if (query) {
query.addEventListener('change', (event) => {
subscribers.forEach((fn) => fn(event.matches));
});
}
export function onMotionPreferenceChange(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn); // unsubscribe
}
// --- Gate 1: Web Animations API ---
export function slideIn(el) {
if (prefersReduced()) {
el.style.opacity = '1'; // show the end state instantly
return null; // no animation object created
}
return el.animate(
[
{ opacity: 0, transform: 'translateY(24px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 360, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both' }
);
}
// --- Gate 2: IntersectionObserver reveal + will-change allocation ---
export function observeReveals(selector) {
// For reduced motion, reveal everything immediately; allocate no layers.
if (prefersReduced()) {
document.querySelectorAll(selector).forEach((el) => {
el.classList.add('is-visible');
});
return null;
}
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target;
el.style.willChange = 'transform, opacity'; // reserve layer just in time
requestAnimationFrame(() => el.classList.add('is-visible'));
el.addEventListener(
'animationend',
() => { el.style.willChange = 'auto'; }, // release the layer
{ once: true }
);
observer.unobserve(el);
});
}, { threshold: 0.1 });
document.querySelectorAll(selector).forEach((el) => observer.observe(el));
return observer;
}
Rendering Impact:
transform+opacity— composite only when motion runs. Underreduceno Animation object is created and nowill-changelayer is allocated, so the compositor does zero motion work.
The matching stylesheet still needs its own reduced-motion branch for the declarative parts, because JavaScript detection and the CSS cascade are complementary, not substitutes:
.reveal { opacity: 0; transform: translateY(24px); }
.reveal.is-visible {
animation: reveal-in 360ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes reveal-in { to { opacity: 1; transform: translateY(0); } }
/* Declarative safety net, in step with the JS gate above */
@media (prefers-reduced-motion: reduce) {
.reveal,
.reveal.is-visible {
animation: none;
opacity: 1;
transform: none;
will-change: auto;
}
}
Rendering Impact:
transform+opacity— composite only. Thereducebranch clears transform and releaseswill-change, matching the JavaScript module’s behaviour exactly.
Verification checklist
Constraints and trade-offs
matchMediachange events useaddEventListener('change', …); the legacyaddListenerform is deprecated. Target the modern API and only shimaddListenerif you must support very old engines.- Reading the preference is synchronous and cheap, but calling
matchMediarepeatedly creates newMediaQueryListobjects; create one and reuse it, as the module does. - Defaulting to reduced motion on the server means users who welcome motion see a brief calm-then-animate transition after hydration. That is the correct trade: a missed frame of motion for opted-in users is far better than a flash of motion for opted-out ones.
- Gating IntersectionObserver by revealing everything immediately can produce a long, un-paced page for reduced-motion users; ensure the static layout is still coherent without the staggered entrance.
- Cancelling a running Web Animations API animation on a runtime preference change should set the element to its end state, not its start, so content never disappears mid-view.
Frequently asked questions
Why detect reduced motion in JavaScript when CSS already handles it? CSS media queries govern declarative animations and transitions, but they cannot reach the Web Animations API, IntersectionObserver-driven reveals, or dynamic will-change allocation. JavaScript detection is needed to gate those imperative code paths so they respect the same preference the CSS cascade already honours.
How do I react when the user changes the preference at runtime? Attach a change event listener to the MediaQueryList returned by matchMedia. When it fires, re-read the matches property and update your animation strategy, cancelling any running Web Animations API playback and releasing will-change if the new value is reduce.
What is the SSR caveat for prefers-reduced-motion detection? window and matchMedia do not exist during server rendering, so calling them throws. Guard the call with a typeof window check and default to reduced motion on the server, so the first client render is calm and any motion is only introduced after hydration confirms the user has not opted out.
Related
- prefers-reduced-motion Architecture — the CSS cascade this JavaScript detection complements
- Reduced-Motion Fallback Patterns for Keyframes — the declarative fallbacks that pair with these gates
- Layer Promotion & will-change Strategy — managing the GPU layers this module allocates and releases