Reduced Motion in Component Libraries
Part of prefers-reduced-motion Architecture in Accessible Motion Architecture.
The problem
A design system ships a dialog, a drawer, a tooltip and a carousel. Two products use them. One added a global reduced-motion stylesheet, so its animations stop; the other did not, so its dialogs still slide and its carousel still autoplays. A third product passes duration={800} to the drawer for a “premium feel” and unintentionally overrides the library’s reduced-motion handling entirely.
If a library leaves reduced motion to its consumers, some consumers will get it wrong, and the accessibility of every product using the library becomes a coincidence.
Root cause analysis: the library owns the behaviour, not the consumer
The component knows which motion is essential. Only the dialog’s author knows that its entrance is decorative while its focus trap is not, or that the carousel’s autoplay is the risky part rather than the slide transition. That knowledge belongs with the component.
Consumers cannot be relied on. Documentation asking integrators to add a media query is the same as not having the feature: it will be missed, and nothing fails visibly when it is.
Overrides are where it breaks. A duration prop that flows straight into an inline style bypasses any CSS media query. If a component accepts motion configuration, that configuration must pass through the same reduced-motion decision as the defaults — usually by mapping props to token names rather than raw values.
Tokens are the seam. If components animate using motion tokens, a consumer can retheme durations and easings globally, and the library’s reduced-motion overrides re-point those same tokens — so the branch keeps working regardless of theme, as described in reduced motion in design tokens.
An app-level preference must be respected. Products that offer their own motion setting need the library to honour it, which means reading an effective preference from context rather than calling matchMedia directly inside each component.
Step-by-step resolution
Production code pattern
/* Inside the library's dialog styles: the branch ships with the component. */
.ds-dialog {
opacity: 0;
scale: 0.97;
transition:
opacity var(--ds-motion-enter-duration, 240ms) linear,
scale var(--ds-motion-enter-duration, 240ms) var(--ds-motion-enter-easing, ease-out),
display var(--ds-motion-enter-duration, 240ms) allow-discrete,
overlay var(--ds-motion-enter-duration, 240ms) allow-discrete;
}
.ds-dialog[open] { opacity: 1; scale: 1; @starting-style { opacity: 0; scale: 0.97; } }
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="full"]) .ds-dialog { scale: 1; transition-duration: 100ms; }
}
:root[data-motion="reduced"] .ds-dialog { scale: 1; transition-duration: 100ms; }
// A provider exposes the effective preference; components never call matchMedia directly.
const MotionContext = createContext('system');
export function MotionProvider({ preference = 'system', children }) {
return <MotionContext.Provider value={preference}>{children}</MotionContext.Provider>;
}
export function useReducedMotion() {
const preference = useContext(MotionContext);
const [system, setSystem] = useState(() => matchMedia('(prefers-reduced-motion: reduce)').matches);
useEffect(() => {
const mq = matchMedia('(prefers-reduced-motion: reduce)');
const on = () => setSystem(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
if (preference === 'reduced') return true;
if (preference === 'full') return false;
return system;
}
// Props select tokens, never raw durations.
export function Drawer({ speed = 'default', children }) {
const reduced = useReducedMotion();
const token = reduced ? 'ds-motion-instant' : { fast: 'ds-motion-fast', default: 'ds-motion-enter-duration' }[speed];
const style = { '--ds-drawer-duration': `var(--${token})` }; // token name, never a raw duration
return <aside className="ds-drawer" style={style}>{children}</aside>;
}
Rendering Impact: unchanged per component; the architecture decides whether motion runs, not how it is rendered. Routing props through tokens means a consumer cannot accidentally set an 800ms duration that survives the reduced branch.
Accepting speed="fast" rather than duration={120} is the key API decision. It keeps every value inside the system, so reduced motion, theming and future changes to the scale all continue to work.
Documenting the reduced branch
Every component’s documentation should state, in one line, what it does when motion is reduced: “Dialog: fades in over 100ms with no scale”; “Carousel: does not autoplay; slide changes are instant”; “Toast: appears without sliding, auto-dismiss unchanged”. This is not optional detail — it tells integrators what their users will see, and it turns the behaviour into something reviewers can check.
Include it in the component’s API table alongside props, and keep a summary page listing every component’s reduced behaviour, which doubles as the checklist for the manual audit.
Verification checklist
Constraints and trade-offs
- Token-only props are less flexible than raw values; provide enough steps to cover real needs.
- A context-based preference requires consumers to mount a provider, with a sensible default when they do not.
- Shipping the branch inside components means library updates can change motion behaviour; document changes.
- Some consumers will still override styles directly; make the overrides obvious in review rather than impossible.
- Server-rendered components must not flash full motion before the preference is known.
Frequently asked questions
Should a component library handle prefers-reduced-motion itself?
Yes. Only the component knows which of its motion is decorative, and relying on consumers means some products will miss it entirely.
How do I let consumers customise durations without breaking reduced motion?
Accept token names or named speeds rather than raw values, and route them through the same tokens the reduced branch overrides.
How should a library respect an app’s own motion setting?
Read an effective preference from a context or data attribute that combines the app setting with the system preference, instead of calling matchMedia in each component.
What should the documentation say?
One line per component describing exactly what happens when motion is reduced, next to the component’s props.
Related
- prefers-reduced-motion Architecture — the parent topic
- Building an In-App Motion Preference Toggle — the app-level setting libraries must respect
- Motion Design Systems & Tokens — the token layer this relies on