Reduced motion is an accessibility preference that asks interfaces to reduce or replace non-essential animation. CSS reads it with @media (prefers-reduced-motion: reduce); JavaScript receives the same signal from window.matchMedia. Build a React hook that reports the preference now and stays current if it changes while the component is mounted.
function usePrefersReducedMotion(defaultValue?: boolean): boolean;
defaultValue defaults to false. Return it safely when window or window.matchMedia is unavailable. After mount in a supporting browser, the media query's matches value is authoritative.
function AnimatedNotice() {
const reduceMotion = usePrefersReducedMotion();
return <aside className={reduceMotion ? 'no-motion' : 'slide-in'}>Saved</aside>;
}
// The browser reports that reduced motion is enabled at mount.
renderHook(() => usePrefersReducedMotion()).result.current; // true
// A later change event reports matches: false.
// The same mounted hook now returns false.
(prefers-reduced-motion: reduce). Read matches after mount and subscribe to the returned MediaQueryList object's change event.matches value replaces defaultValue, even when they disagree.removeEventListener on the same query object.window during render; unsupported and server environments keep defaultValue without throwing.You'll mirror the browser's reduced-motion signal into React state, listen for later changes, and release the listener when the component unmounts.
Some people enable reduced motion because animated zooms, parallax, or rapid transitions can cause discomfort. The preference belongs to the browser, not to your component. A hook can read that external value, but a one-time read becomes stale if the person changes the setting while your page remains open. Your implementation therefore needs both a current snapshot and a subscription.
Treat the MediaQueryList as a small external signal. Its matches property is the current snapshot, and its change event announces later snapshots. React state mirrors those values so consumers render the current preference.
The tempting version reads the media query in the state initializer:
const { useState } = require('react');
function usePrefersReducedMotion() {
const [reduceMotion] = useState(() =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
);
return reduceMotion;
}
This can return the right value at mount, so the missing behavior is easy to overlook. The initializer never runs again, which means no later preference change reaches React. It also reads window during render and throws when the browser API is absent.
const { useEffect, useState } = require('react');
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion(defaultValue = false) {
// The first render must not depend on browser-only APIs.
const [prefersReducedMotion, setPrefersReducedMotion] = useState(
Boolean(defaultValue),
);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
const handleChange = (event) => setPrefersReducedMotion(event.matches);
// The browser snapshot is authoritative once the effect can read it.
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
module.exports = { usePrefersReducedMotion };
The initial state uses only caller data, so rendering stays safe without window. The mount-only effect acquires one MediaQueryList, reconciles from its authoritative matches value, and registers one named handler. Cleanup closes over that same query object and handler, which gives removeEventListener the exact pair used for subscription.
Suppose defaultValue is false, but the browser already has reduced motion enabled:
Boolean(false) produces false without reading window.matchMedia('(prefers-reduced-motion: reduce)') once.matches: true, so the hook sets state to true and React re-renders.handleChange to that list's change event.{ matches: false }; the handler stores false, and the consumer re-renders again.handleChange from the original list. Later events cannot update the abandoned hook.Mount, cleanup, and each preference change take O(1) time. Each mounted hook instance stores O(1) state and owns one listener.
no-preference as another query. The contract asks whether reduce matches, so false already represents no request to reduce motion. Fix: use the exact reduce query and return its boolean.mediaQuery.matches into state after mount.window. The change event belongs to the returned MediaQueryList. Fix: subscribe and unsubscribe on that object.useMediaQuery(query, defaultValue) and define reduced motion by passing this exact query.useSyncExternalStore when many consumers should share one subscription and need concurrent-render snapshot semantics.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Reduced motion is an accessibility preference that asks interfaces to reduce or replace non-essential animation. CSS reads it with @media (prefers-reduced-motion: reduce); JavaScript receives the same signal from window.matchMedia. Build a React hook that reports the preference now and stays current if it changes while the component is mounted.
function usePrefersReducedMotion(defaultValue?: boolean): boolean;
defaultValue defaults to false. Return it safely when window or window.matchMedia is unavailable. After mount in a supporting browser, the media query's matches value is authoritative.
function AnimatedNotice() {
const reduceMotion = usePrefersReducedMotion();
return <aside className={reduceMotion ? 'no-motion' : 'slide-in'}>Saved</aside>;
}
// The browser reports that reduced motion is enabled at mount.
renderHook(() => usePrefersReducedMotion()).result.current; // true
// A later change event reports matches: false.
// The same mounted hook now returns false.
(prefers-reduced-motion: reduce). Read matches after mount and subscribe to the returned MediaQueryList object's change event.matches value replaces defaultValue, even when they disagree.removeEventListener on the same query object.window during render; unsupported and server environments keep defaultValue without throwing.You'll mirror the browser's reduced-motion signal into React state, listen for later changes, and release the listener when the component unmounts.
Some people enable reduced motion because animated zooms, parallax, or rapid transitions can cause discomfort. The preference belongs to the browser, not to your component. A hook can read that external value, but a one-time read becomes stale if the person changes the setting while your page remains open. Your implementation therefore needs both a current snapshot and a subscription.
Treat the MediaQueryList as a small external signal. Its matches property is the current snapshot, and its change event announces later snapshots. React state mirrors those values so consumers render the current preference.
The tempting version reads the media query in the state initializer:
const { useState } = require('react');
function usePrefersReducedMotion() {
const [reduceMotion] = useState(() =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
);
return reduceMotion;
}
This can return the right value at mount, so the missing behavior is easy to overlook. The initializer never runs again, which means no later preference change reaches React. It also reads window during render and throws when the browser API is absent.
const { useEffect, useState } = require('react');
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion(defaultValue = false) {
// The first render must not depend on browser-only APIs.
const [prefersReducedMotion, setPrefersReducedMotion] = useState(
Boolean(defaultValue),
);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
const handleChange = (event) => setPrefersReducedMotion(event.matches);
// The browser snapshot is authoritative once the effect can read it.
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
module.exports = { usePrefersReducedMotion };
The initial state uses only caller data, so rendering stays safe without window. The mount-only effect acquires one MediaQueryList, reconciles from its authoritative matches value, and registers one named handler. Cleanup closes over that same query object and handler, which gives removeEventListener the exact pair used for subscription.
Suppose defaultValue is false, but the browser already has reduced motion enabled:
Boolean(false) produces false without reading window.matchMedia('(prefers-reduced-motion: reduce)') once.matches: true, so the hook sets state to true and React re-renders.handleChange to that list's change event.{ matches: false }; the handler stores false, and the consumer re-renders again.handleChange from the original list. Later events cannot update the abandoned hook.Mount, cleanup, and each preference change take O(1) time. Each mounted hook instance stores O(1) state and owns one listener.
no-preference as another query. The contract asks whether reduce matches, so false already represents no request to reduce motion. Fix: use the exact reduce query and return its boolean.mediaQuery.matches into state after mount.window. The change event belongs to the returned MediaQueryList. Fix: subscribe and unsubscribe on that object.useMediaQuery(query, defaultValue) and define reduced motion by passing this exact query.useSyncExternalStore when many consumers should share one subscription and need concurrent-render snapshot semantics.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.