useReducedMotion hook
@interlace/ui — the `useReducedMotion` hook every interactive primitive uses to gate animations on the user's OS preference.
Preview
Rendered from the story the storybook (a11y) CI gate runs axe against — the preview can't show something that hasn't been verified.
Install
Two equivalent paths — the URL works in any shadcn-CLI setup; the alias works once you've registered @interlace in your components.json.
npx shadcn@latest add https://ds.interlace.tools/r/use-reduced-motion.jsonnpx shadcn@latest add @interlace/use-reduced-motionHistory
This component is at v1.1.0, first shipped in @interlace/ui 1.0.0. The version is stamped as a banner into the file the install writes, so the copy in your tree says which one you have — compare it with the number above before deciding whether to re-run the install.
@interlace/ui 1.1.01 entry
- Changedpatch
Six defects found by upgrading a real consumer, plus five found by reading the components closely enough to document them. **
Meteors' glow never painted.** It readvar(--color-meteor-glow)while itscssVarsdeclare--meteor-glow;--color-*is the Tailwind@themenamespace and onlycssVars.themepopulates it, so the wholebox-shadowwas invalid at computed-value time. **This was broken only for registry consumers** — our own docs site hand-declares the--color-form. **ArticleCardcropped 28% off every cover.**h-44is 176px; at a ~302px tile that is a 1.72:1 box against a 2.381:1 image. Nowaspect-[1000/420]— the ratio the card already declared on its<img>. It also gains arenderImageslot, because every Next.js consumer was re-patching the same line to usenext/imageand the design system cannot depend on it. **BorderBeamandStarsBackgroundhad noaria-hiddenat all** — six purely decorative nodes a screen reader walked. **CloudParticlesdefaultedbodyColortocurrentColor**, painting volumetric clouds in the inherited text colour. **NumberTickergainsnotation**, because six-figure metrics overflow a tile at 320px. Also:SheetComposeandDialogComposeeach mounted a second backdrop, so a composed dialog dimmed the page twice as much as the hand-composed tree the docs show;AccordiondroppedclassNameon the animated Panel;Tooltipaccepteddelayand discarded it;PopoverAnchorwas a second trigger. **useReducedMotionwas one frame late.** The canonicaluseState(false)plus effect returnsfalseon the first render, so every gated component painted one frame of exactly the motion the user turned off.useSyncExternalStorereads during render and closes that on client renders; on hydration the server cannot know the preference, which is what the stylesheet reset is for.Badgedrops'use client'— verified with a real server-component build.
@interlace/ui 1.0.01 entry
- Addedminor
Day-one install surfaces: three starter bundles that pull a curated set of items in one command, and the two library utilities every primitive needs.
Import
Single named export.
import { useReducedMotion } from '@/hooks/use-reduced-motion';Accessibility
Every story for this component is rendered headlessly and checked with axe-core (wcag2aa, wcag22aa, best-practice, ACT) on every PR. That gate has no continue-on-error, so what ships has zero known violations.
What follows is what static analysis can see. Axe cannot press a key and never sees an overlay open, so the operable-without-a-mouse claim lives in Behavior instead, where the keyboard path is replayed step by step.
- Focus + keyboard behaviour
- Native element semantics — no interaction layer to get wrong.
- Focus ring (WCAG 2.2 SC 2.4.13)
- Not focusable — no focus indicator required.
- Reduced motion
- Animation is gated on prefers-reduced-motion.
- ARIA in the source
- None — semantics come from the element or the Base UI primitive rather than hand-written ARIA.
Examples
2 more states from the same story file.
Dependencies
- Base UI primitive
- Native / no Base UI dependency
- Lucide icons
- none
- NPM dependencies
- none
- Registry dependencies
- none
Source
The full implementation — hooks/use-reduced-motion.ts once installed.
'use client';
import { useCallback, useSyncExternalStore } from 'react';
// @interlace/use-reduced-motion v1.1.0 — Interlace design system.
// Docs, props and live preview: https://ds.interlace.tools/c/use-reduced-motion
// What changed since: https://ds.interlace.tools/c/use-reduced-motion#history
// Generated banner — keep it, the upgrade diff reads this version.
const QUERY = '(prefers-reduced-motion: reduce)';
/**
* Returns `true` when the user has `prefers-reduced-motion: reduce` set.
*
* Use to gate motion-heavy components: animation should be disabled or
* dramatically reduced when this returns `true`.
*
* ## Why `useSyncExternalStore` and not `useState` + `useEffect`
*
* The canonical hook — the one MUI, Vercel, Linear and Stripe all ship — is
…57 more lines…Registry JSON
The raw registry record — what the shadcn CLI fetches.