Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** The one media query this hook exists for. Fixed — that is the whole point. */
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"

/**
 * Lazily created once, then shared by every caller.
 *
 * `window.matchMedia()` mints a **new** `MediaQueryList` on every call, and
 * `getSnapshot` runs on every render of every subscribed component. Calling it
 * inline would allocate one object per component per render — on a page where
 * thirty components each gate their own animation, for nothing. One instance,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-prefers-reduced-motion.json

Prompt

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

  • useSyncExternalStore as 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 MediaQueryList for 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 single change listener 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 readusePrefersReducedMotion() 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.

On This Page