Why Motion Accessibility Matters
Animations can add significant value to a user experience, but they don't affect everyone the same way. For some people, motion on screen can trigger physical symptoms like nausea, dizziness, and headaches. This is linked to the vestibular system—the inner ear and brain structures that regulate balance and spatial orientation. When visual motion conflicts with what the vestibular system senses, the result can be deeply uncomfortable.
Vestibular disorders are not rare. Estimates suggest that up to 35% of adults over 40 in the US have experienced some form of vestibular dysfunction, with about 5% reporting chronic issues. Designers and developers should not overlook this segment of the audience when building animated interfaces.
Respecting the Operating System Setting
All major operating systems—macOS, Windows, Linux, iOS, and Android—now include an accessibility setting that lets users reduce or disable motion. This setting primarily controls OS-level animations, but the prefers-reduced-motion media query exposes that same user preference to the web. Browser support for this media query is strong across modern browsers.
It is critical to ensure that animations are disabled by default for users on browsers or operating systems that do not support the feature. If the media query isn't recognized, the CSS inside it should be ignored, effectively falling back to a no-animation state.
The CSS-Only Approach
A straightforward way to handle this is to start from a baseline of no animations, then enable them only when the media query indicates the user has no preference for reduced motion.
/* Default: no transition */
.box {
opacity: 1;
}
/* Enhanced experience for users who opt in */
@media (prefers-reduced-motion: no-preference) {
.box {
transition: opacity 2s;
}
}
The no-preference value is the default for users who haven't changed their accessibility settings, so they will still experience animations without needing to act. This pattern is superior because it doesn't rely on disabling animations after the fact; it simply never applies them for users who might be affected.
Handling JavaScript-Driven Animations
Many animations cannot be accomplished with pure CSS. These include spring-physics-based motion, animations that depend on cursor coordinates or scroll position, HTML5 canvas renders, and certain SVG effects. For these cases, the media query is still accessible from JavaScript through window.matchMedia.
const mediaQueryList = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
if (mediaQueryList.matches) {
// Start animation
} else {
// Skip animation: user prefers reduced motion or no support
}
Note that this logic checks for the no-preference state. If the media query is not supported, mediaQueryList.matches will be false, which correctly defaults the experience to no animation. The boolean is inverted to derive the "prefers reduced motion" flag.
You can also attach a change listener to respond if the user toggles this setting while the page is open. This allows immediate termination of in-progress animations.
mediaQueryList.addEventListener('change', (event) => {
if (event.matches) {
// The user no longer has a preference for reduced motion
// Start animations
} else {
// The user enabled the reduce motion setting
// Stop all animations immediately
}
});
This event fires only when the preference changes, not on page load.
A React Hook for Motion Preferences
These browser APIs can be tied into the React lifecycle with a reusable hook:
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(
() => !window.matchMedia('(prefers-reduced-motion: no-preference)').matches
);
useEffect(() => {
const mediaQueryList = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
const listener = (event) => {
setPrefersReducedMotion(!event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => mediaQueryList.removeEventListener('change', listener);
}, []);
return prefersReducedMotion;
}
This hook initializes state with the current media query value, registers a listener on mount to update state on changes, and cleans up the listener on unmount. The empty dependency array ensures the effect runs only once.
SSR Considerations
For Gatsby or Next.js applications, referencing window during the initial render throws an error, since the component tree is pre-rendered on the server where window doesn't exist. To be SSR-safe, the hook must not access window in its first render phase.
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(
() => null
);
useEffect(() => {
const mediaQueryList = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
setPrefersReducedMotion(!mediaQueryList.matches);
const listener = (event) => {
setPrefersReducedMotion(!event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => mediaQueryList.removeEventListener('change', listener);
}, []);
return prefersReducedMotion;
}
The initial value in the SSR-safe version is null, which is unreliable for rendering. The recommendation is to compromise: treat it as if the user prefers reduced motion for the first render. For example, if you interpret null as a preference for no motion, all users see a static initial frame, and animations only start after the client-side effect runs.
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(true);
useEffect(() => {
const mediaQueryList = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
setPrefersReducedMotion(!mediaQueryList.matches);
const listener = (event) => {
setPrefersReducedMotion(!event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => mediaQueryList.removeEventListener('change', listener);
}, []);
return prefersReducedMotion;
}
This variant defaults to true, disabling animations for everyone on the first render. Follow this pattern only in SSR contexts.
Integrated with Animation Libraries
A library like React Spring uses spring physics to drive motion. It accepts an immediate prop which, when true, skips all animation. By passing the hook's returned boolean directly to this prop, the animation is disabled instantly when the user prefers reduced motion.
The same hook can also work with inline styles or styled-components for CSS transitions, though it's not always the best fit. For purely CSS-based transitions, the media query is simpler and clearer:
const Styles = styled.div`
/* Default: disabled */
transition: none;
@media (prefers-reduced-motion: no-preference) {
transition: transform 300ms;
}
`;
Choosing the CSS media query over the JS hook is preferable when the animation is just a transition, because it keeps the logic close to the styles it affects and avoids unnecessary re-renders.
Testing the Reduced Motion Experience
It's essential to verify that your application works correctly with reduced motion enabled. Beyond toggling the OS-level setting, browsers provide built-in emulation. In Chrome's DevTools, open the Command Palette with Ctrl + Shift + P (on Windows/Linux) or the equivalent, type reduce, and select the rendering emulation for prefers-reduced-motion: reduce. The emulation applies to the current tab only and resets when DevTools is closed.
Shared Responsibility
The existence of prefers-reduced-motion across every major browser is a strong signal of industry cooperation. Both browser vendors and operating system developers have invested in building this infrastructure. The final piece of the puzzle is for developers to consume it correctly at the application level. Treating every user as having the same tolerance for motion ignores real physical differences. Defaulting to no motion and progressively enhancing for those who can enjoy it remains the safest and most considerate strategy.



