Detecting prefers-reduced-motion in JavaScript with matchMedia
CSS handles the declarative half of motion reduction, but any animation started from JavaScript β document.startViewTransition(), element.animate(), a ScrollTimeline constructed in script β never sees a media query. The only reliable signal on the JS side is window.matchMedia('(prefers-reduced-motion: reduce)'), and using it correctly means treating it as a live subscription rather than a one-off boolean. This page covers the query itself, the change event for mid-session preference flips, guard placement for View Transitions and the Web Animations API, and SSR-safe initialisation. It is the JavaScript companion to the CSS override patterns in the parent guide on implementing prefers-reduced-motion, within the broader Accessibility & Inclusive Motion Standards section.
When to use this approach
Reach for matchMedia detection when CSS alone cannot see or stop the motion:
document.startViewTransition()calls β the snapshot machinery starts in JS before any::view-transition-*pseudo-element exists, so only a JS guard prevents the GPU snapshot allocation.- Web Animations API (
element.animate(),new Animation()) β WAAPI animations are created imperatively and are not matched by@media (prefers-reduced-motion: reduce)blocks at all. - JS-constructed
ScrollTimeline/ViewTimelineobjects β a timeline passed toelement.animate(keyframes, { timeline })bypasses theanimation-timelineproperty that CSS reduced-motion overrides reset. - Inline-style animation drivers in React, Vue, or Svelte β inline styles outrank every stylesheet rule, so the preference must reach the component as state.
- Analytics or conditional loading β skipping the download of a heavy animation library entirely when the user prefers reduced motion.
When the motion is declared purely in CSS (animation, transition, animation-timeline), prefer the media-query override patterns β they need no JS, cannot race hydration, and update instantly. Auditing which of your animations fall under the WCAG 2.2 animation compliance criteria first tells you which ones must also be pausable regardless of the OS preference.
Implementation
Step 1: Create the MediaQueryList and read .matches
window.matchMedia() takes the same query string you would write inside @media and returns a MediaQueryList β a live object, not a snapshot. Create it once per page and keep the reference:
// One shared MediaQueryList for the whole app β module scope is fine
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// Synchronous read of the current preference
console.log(motionQuery.matches); // true when the OS setting is on
Two details matter. First, query for reduce, not no-preference: browsers that do not implement the feature evaluate the unknown query to false, which fails safe β motion runs, exactly as if the user had expressed no preference. Querying no-preference inverts that failure mode. Second, do not recreate the list per call. Every matchMedia() invocation allocates a fresh MediaQueryList, and listeners you attach to a throwaway instance can be garbage-collected in some engines, silently dropping your subscription.
Step 2: Subscribe to change for live preference flips
Users toggle the OS setting mid-session β often because your page just made them motion-sick. .matches read at startup goes stale the moment that happens. Subscribe to the change event so every consumer updates without a reload:
let reducedMotion = motionQuery.matches;
function onMotionPreferenceChange(event) {
reducedMotion = event.matches;
// Mirror the state into the DOM so CSS can react too
document.documentElement.dataset.reducedMotion = String(event.matches);
// Stop WAAPI animations that are already running
if (event.matches) {
document.getAnimations().forEach((animation) => {
// Jump to the end state instead of freezing mid-flight
animation.finish();
});
}
}
motionQuery.addEventListener('change', onMotionPreferenceChange);
Calling animation.finish() rather than animation.cancel() matters: cancel() discards the effect and can leave elements stranded at their from state (invisible, off-screen), while finish() jumps to the final keyframe and fires finish events so cleanup code still runs. Note that finish() throws InvalidStateError on infinite-iteration animations β cancel() those after manually applying their resting styles.
Legacy note: all current engines support addEventListener('change', β¦); the old mql.addListener(handler) form is only needed for long-dead WebKit builds.
Step 3: Guard startViewTransition() and WAAPI calls
The View Transitions API is the highest-value place for this guard, because CSS can only shorten the crossfade β it cannot prevent startViewTransition() from snapshotting every named element into GPU buffers. Branch before the call:
function transitionTo(updateDOM) {
// One expression handles both "reduced motion" and "API missing"
if (motionQuery.matches || !document.startViewTransition) {
updateDOM(); // instant swap: no snapshots, no crossfade
return;
}
return document.startViewTransition(updateDOM);
}
In a router-driven SPA, centralise this in the navigation layer β the React Router view-transition integration shows where that hook lives β so individual views never call startViewTransition() directly.
Web Animations API calls need the same branch. The cleanest pattern collapses duration instead of skipping the call, so finished promises and finish events keep resolving:
function animateGuarded(element, keyframes, options) {
const reduced = motionQuery.matches;
return element.animate(keyframes, {
...options,
// 1ms, not 0: zero-duration WAAPI effects can skip event dispatch
duration: reduced ? 1 : options.duration,
// Drop scroll-linked timelines entirely under reduced motion
timeline: reduced ? document.timeline : options.timeline,
});
}
Replacing a ScrollTimeline with document.timeline is the WAAPI equivalent of the CSS animation-timeline: auto reset β without it, the animation stays tethered to scroll position no matter how short its duration is.
Step 4: Handle SSR and hydration
window does not exist during server rendering, so any module-scope matchMedia call crashes Node. Wrap the read, and decide on a deliberate server-side default:
function getInitialMotionPreference() {
if (typeof window === 'undefined') {
return false; // server: assume full motion, correct after mount
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
Defaulting to false keeps server and client markup identical for the majority of users, avoiding hydration mismatches. The cost: a user with reduced motion enabled may see one animated frame before the client-side value corrects it. To close even that gap, gate initial animations behind CSS β which evaluates before first paint, with no JS round-trip β and reserve the JS value for imperative calls only. An inline <script> in <head> that stamps document.documentElement.dataset.reducedMotion before the framework boots achieves the same for attribute-driven styles.
Step 5: Wrap the subscription in a framework hook
Every framework wants the same three-part contract: initial read, subscription, cleanup. Reactβs useSyncExternalStore expresses it exactly, including the SSR default:
import { useSyncExternalStore } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(callback) {
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
export function useReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches, // client snapshot
() => false // server snapshot
);
}
Vue and Svelte versions are shorter because their reactivity systems own the store:
// Vue 3 composable
import { ref, onMounted, onUnmounted } from 'vue';
export function useReducedMotion() {
const reduced = ref(false);
let mql;
const update = (e) => { reduced.value = e.matches; };
onMounted(() => {
mql = window.matchMedia('(prefers-reduced-motion: reduce)');
reduced.value = mql.matches;
mql.addEventListener('change', update);
});
onUnmounted(() => mql?.removeEventListener('change', update));
return reduced;
}
In Svelte 5, the same logic fits a $effect rune that returns its own teardown function. Whichever framework you use, always return the unsubscribe function β a leaked change listener holds its closure (often an entire component tree) alive for the life of the page.
Verification
- Chrome DevTools β Rendering tab β Emulate CSS media feature
prefers-reduced-motionβreduce. Confirmwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesreturnstruein the console. - Flip the emulation between
reduceandno-preferencewhile watching yourchangehandler β add a temporaryconsole.logβ to confirm live flips propagate without reload. - Trigger a route change with emulation on. In the Performance panel recording, there must be no
ViewTransitionsnapshot activity and noAnimationevents from your guarded calls. - Console check for WAAPI leaks:
document.getAnimations().filter(a => a.playState === 'running' && a.timeline !== document.timeline)β must be empty under reduced motion; any survivor is still scroll-linked. - Automate it in Playwright with
page.emulateMedia({ reducedMotion: 'reduce' })before navigation, then assert on the samegetAnimations()expression.
Emulation only covers the browser you develop in. The real OS toggles β and the places where they disagree with each other β are catalogued in the walkthrough on testing prefers-reduced-motion across operating systems and browsers; run at least one real-OS pass before shipping.
Edge cases and gotchas
A stale .matches snapshot is worse than none. Copying motionQuery.matches into a plain variable at module load and never updating it means mid-session flips are ignored. Either read .matches at each decision point or maintain the value through the change subscription β never both patterns half-heartedly.
change fires for any flip, in both directions. The handler receives event.matches for the new state; do not assume the event means βreduce was enabledβ. Re-enabling motion mid-session should not restart finished entrance animations, so gate re-enable logic separately.
Zero-duration WAAPI animations may skip events. As with CSS, a duration: 0 effect can complete without dispatching finish in some engines. Use 1 millisecond as the floor when collapsing durations.
Iframes evaluate the query in their own context. A matchMedia call inside a cross-origin iframe reflects that documentβs environment. Embedded widgets must run their own detection; the parent page cannot inject its state across origins.
DevTools emulation does not fire OS-level hooks. Emulation changes the media query result but not navigator state or any OS API. This is fine β matchMedia is the only signal you should be reading anyway β but it means emulation cannot exercise code that (incorrectly) sniffs platform settings by other means.
Guard the guard in tests. jsdom does not implement matchMedia; unit tests need a stub that returns { matches: false, addEventListener() {}, removeEventListener() {} } or every hook above throws at mount.
Browser-specific notes
Chrome / Edge (Chromium). matchMedia with prefers-reduced-motion has been stable since Chrome 74, and animation-timeline since Chrome 115 β so Chromium is the one engine where the full stack (scroll-linked CSS timelines, JS ScrollTimeline, View Transitions) is live and every guard on this page is exercised. Same-document startViewTransition() landed in Chrome 111 and cross-document @view-transition in Chrome 126.
Safari (WebKit). prefers-reduced-motion support is the oldest of any engine (Safari 10.1), and same-document View Transitions shipped in Safari 18, with cross-document support from Safari 18.2. CSS scroll-driven animations are in active development β available in Technology Preview builds β so the WAAPI ScrollTimeline branch in Step 3 currently no-ops in release Safari; keep it for forward compatibility.
Firefox (Gecko). matchMedia detection and the change event work fully (support since Firefox 63). Scroll-driven animation-timeline remains behind a pref in Nightly, and same-document View Transitions are in development in the Firefox 144+ line β so in release Firefox the !document.startViewTransition half of the Step 3 guard is what routes users to the instant-swap path. Verify that fallback path deliberately rather than assuming the transition branch ran; the progressive enhancement guide covers structuring code so the fallback is the baseline, not an afterthought.
Related
- Motion Preference Support Matrix: Browsers & Operating Systems β which OS toggles map to
prefers-reduced-motionper platform and engine - Motion Scaling & User Preferences β turning the binary preference into a proportional
--motion-scalefactor - Building a Motion Intensity Toggle with CSS Custom Properties β an in-page control that layers on top of the
matchMediasignal - Detecting View Transition Support with @supports and JavaScript β the feature-detection half of the
startViewTransitionguard - How the View Transition API Works Under the Hood β why the snapshot phase must be skipped in JS rather than suppressed in CSS