Skip to contentVibraUI
Foundation

useReducedMotion

Whether the page is asking for less motion — the reader's OS setting, or a data-motion switch above.

Two signals, one answer. The media query covers the reader's own setting; [data-motion="reduced"] covers a page or a preview frame asking for the same, and is read the way the theme states it — off --duration-base, which both signals zero. That is what keeps JavaScript that has to decide (recharts' isAnimationActive is a prop, not a stylesheet) in step with the CSS half, instead of inventing a second convention. False on the server and on the first client render, so the markup React hydrates is the markup it rendered; the first effect settles it. Anything that can be expressed in CSS should be — the duration tokens are already zero under both switches — so reach for this only where a prop has to change.

Install

npx shadcn@latest add @vibra/use-reduced-motion

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
returnsboolean—True while either signal is asking for less motion.

Dependencies

Registry

Source

hooks/use-reduced-motion.ts
"use client"

import * as React from "react"

const QUERY = "(prefers-reduced-motion: reduce)"

/**
 * Whether the document is currently asking for less motion.
 *
 * Two signals, one answer: the reader's OS setting, and `[data-motion="reduced"]`
 * on an ancestor — the switch a preview frame or a page flips to ask for the
 * same thing. The second is read the way the theme states it, off
 * `--duration-base`: the token is zeroed by both, so JavaScript that has to
 * decide (recharts' `isAnimationActive` is a prop, not a stylesheet) agrees with
 * the CSS half without a second code path or a second convention.
 */
export function prefersReducedMotion(element?: Element | null): boolean {
  if (typeof window === "undefined") return false
  if (window.matchMedia?.(QUERY).matches) return true

  const target = element ?? document.documentElement
  const raw = window.getComputedStyle(target).getPropertyValue("--duration-base").trim()
  // An install without the theme resolves the token to "", which is not a
  // request for less motion — only an explicit zero is.
  if (raw === "") return false
  return Number.parseFloat(raw) === 0
}

/**
 * Tracks {@link prefersReducedMotion}, re-reading when the OS setting changes
 * and when a `data-motion` attribute is added or removed anywhere above.
 *
 * Pass a ref to the node that should answer: the token is inherited, so reading
 * it there sees a `data-motion` set anywhere between that node and the root.
 * Without one the document root answers, which is right for a whole page and
 * wrong inside a frame that asked for less motion on its own wrapper.
 *
 * False on the server and on the first client render, so the markup React
 * hydrates is the markup it rendered; the first effect settles it before paint.
 */
export function useReducedMotion(element?: React.RefObject<Element | null>): boolean {
  const [reduced, setReduced] = React.useState(false)

  React.useEffect(() => {
    // Read from the caller's own node when it has one. The token is inherited,
    // so any element inside the subtree carries the answer — and reading the
    // document root instead missed `[data-motion="reduced"]` set on anything
    // below it, which is exactly where a preview frame sets it.
    const read = () => setReduced(prefersReducedMotion(element?.current))
    read()

    const mql = window.matchMedia?.(QUERY)
    mql?.addEventListener("change", read)

    // `data-motion` may be set on the html element, on a preview frame, or on a
    // wrapper in between, so the whole subtree is watched for the attribute
    // rather than one known node.
    const observer =
      typeof MutationObserver === "function"
        ? new MutationObserver(read)
        : undefined
    observer?.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["data-motion"],
      subtree: true,
    })

    return () => {
      mql?.removeEventListener("change", read)
      observer?.disconnect()
    }
  }, [element])

  return reduced
}