usePrefersReducedMotion
An SSR-safe hook that reports whether the user has asked the system to reduce motion, and updates live when the OS setting changes.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-prefers-reduced-motion.jsonPrompt
Build a React + TypeScript "usePrefersReducedMotion" hook (no dependencies
beyond React; uses the browser matchMedia API only).
Contract
- `usePrefersReducedMotion(): boolean` — no arguments, no options object. The
query is fixed at `(prefers-reduced-motion: reduce)`; that fixed-ness is the
reason this exists as its own hook instead of a call into a generic
`useMediaQuery`.
- `getPrefersReducedMotion(): boolean` — the same read, once, without
subscribing. For imperative code outside render: a click handler deciding
whether to fire a confetti burst, a toast helper picking its enter
animation, a canvas routine kicked off by an event.
- Default-export the hook as well as naming it, so consumers can import it
either way.
Behavior
- Implemented with `useSyncExternalStore`, never `useEffect` + `useState`.
`matchMedia` is the textbook external mutable source: it changes outside
React's render cycle (the user flips the OS setting, or an automation does
at sunset) and must be read tear-free under concurrent rendering. Hand
React the three primitives and let it decide when to re-render — there is
then no effect body calling `setState`, no double render on mount, and no
window where the rendered value disagrees with the live query.
- `subscribe(onStoreChange)`: attach `onStoreChange` to the media query
list's `change` event; return a cleanup that removes exactly that
listener.
- `getSnapshot()`: return the media query list's `.matches`.
- `getServerSnapshot()`: return `false`.
- Declare `subscribe` / `getSnapshot` / `getServerSnapshot` at module scope,
not inside the hook. Their identities never change, so React never tears the
subscription down and rebuilds it, and there is nothing for them to close
over — the query is a constant.
- Cache one `MediaQueryList` at module scope and hand it to every caller.
`window.matchMedia()` mints a new object on every call and `getSnapshot`
runs on every render of every subscriber; on a page where thirty components
each gate an animation that is one allocation per component per render for
nothing. One instance, N `change` listeners on it, each removed by its own
subscription cleanup on unmount.
- Environment guard, checked before touching the API: bail to `null` when
`typeof window === "undefined"` (server) or when `typeof window.matchMedia
!== "function"` (old WebViews, a test environment with no polyfill). Both
absences read as `false` — "no preference expressed" — and `subscribe`
returns a no-op cleanup in that case rather than throwing.
- The server snapshot is a deliberate `false`, not a configurable guess. The
server has no OS to ask, and the two guesses fail differently: `true`
renders the static variant into the HTML for *everyone*, so every visitor's
first paint is the reduced branch and hydration pops it into motion, while
`false` matches the majority, keeps the server HTML identical to the first
client render (no hydration mismatch), and costs the minority at most the
frame between hydration and the store's first real read. The CSS half of
their experience is already correct at first paint without any JavaScript.
- The value can flip mid-session, so anything it gates must be able to stop:
the returned boolean belongs in the dependency array of the effect that owns
the rAF loop / interval / observer, and that effect's cleanup cancels the
loop. An animation started while the value was `false` dies when it turns
`true`, and lands on its end state rather than freezing mid-way (do that
rewind with React's adjust-state-during-render pattern — compare the
previous value to the current one during render and reset progress there —
not with another `setState` inside an effect).
Rendering & styling
- The hook renders nothing and owns no markup; consumers branch on the
boolean. What it obliges them to get right:
- `true` means "no movement", not "no feedback". Replace the journey, keep
the destination: a count-up commits its final number, a slide-in appears
in place, a spinner becomes a static loading label. Deleting the feedback
entirely leaves a reduced-motion user with a UI that looks unresponsive.
- Anything that moves on its own (carousel, marquee, ticker) needs a pause
control per WCAG 2.2.2 — and under reduce, the honest answer is usually to
never start the timer and render every item at once instead, so nothing is
hidden behind an animation the user cannot see.
- Reach for CSS first: `motion-reduce:transition-none`,
`motion-reduce:animate-none`, `motion-reduce:hidden` for decorative
layers. Use this hook only for what CSS cannot switch off — whether to
*start* a loop, a timer, an autoplay, a physics simulation.
- Any UI built on it uses semantic tokens only (`bg-card`, `bg-muted`,
`bg-primary`, `text-muted-foreground`, `border`, `ring`) and merges
incoming `className` through `cn()`.
- The preference changes nothing about the ARIA contract: the same roles,
the same labels, the same `aria-live` politeness in both branches. Do not
announce "reduced motion is on" to a screen reader — the user set it.
Customization levers
- An in-app override (a Reduce motion switch in your own settings page): keep
this hook as the OS truth and OR it with your stored preference at the call
site — `const reduced = usePrefersReducedMotion() || settings.reduceMotion`
— rather than threading an option through the hook.
- Intensity instead of on/off: some designs want reduce to mean "shorter and
smaller", not "nothing". Derive it at the call site
(`const duration = reduced ? 0 : 900`, `const distance = reduced ? 0 : 24`)
so a single boolean still drives a spectrum of effects.
- Supporting Safari below 14: swap `addEventListener("change", cb)` /
`removeEventListener` for the deprecated `addListener` / `removeListener`
pair behind a feature check inside `subscribe`. The rest of the hook is
untouched.
- Other preference queries follow the same shape one file over — swap the
query constant for `(prefers-contrast: more)`, `(prefers-reduced-transparency:
reduce)`, `(prefers-color-scheme: dark)` or `(forced-colors: active)` and
keep the module-level singleton, the `false` server snapshot and the guards.Concepts
useSyncExternalStoreas the preference adapter — the hook never reconciles state by hand; it hands React a subscribe, a client snapshot and a server snapshot, which is what makes the value tear-free under concurrent rendering and correct on the very first client render instead of one effect-tick later.- The server snapshot is a chosen lie — nothing on the server can read an OS setting, so
false(motion allowed) is a decision, not a measurement: it keeps the server HTML equal to the first client render, and it fails toward the majority. The reduced-motion user's CSS-driven experience is already right at first paint without JavaScript, so the only thing this guess can cost them is the frame between hydration and the store's first read. - One
MediaQueryListfor the whole app — the query is a constant, so the media query list is cached at module scope and every consumer subscribes to the same object; the per-component cost is a singlechangelistener that its own subscription cleanup removes on unmount. - A live preference, not a mount-time reading — the OS setting can flip mid-session and this hook re-renders on it, which is precisely why the boolean belongs in the dependency array of whichever effect owns the loop: the cleanup that cancels a running animation is the whole point of subscribing rather than reading once.
- Swap the journey, keep the destination — reduce means no movement, not no feedback. The reduced branch of a count-up still shows 1,284; the reduced branch of a carousel still shows every slide. A branch that simply removes the effect leaves the user with a UI that appears not to have responded.
- Subscription vs. one-shot read —
usePrefersReducedMotion()is for components whose render depends on the preference;getPrefersReducedMotion()is for handlers that only consult it at the moment of an event, and that therefore have no reason to re-render when the setting changes.
useFocusWithin
A callback-ref hook that reports whether focus is inside a subtree — no flicker when focus moves between children, and it still flips off when the focused node is removed.
useCookie
A cookie-as-state hook with typed attributes, cross-instance sync, an SSR-safe default snapshot, and writes that refuse instead of silently vanishing over the 4 KB cap.