Animation in Design Systems: Tokens, Reduced Motion, Choreography
How to build animation into a design system properly — tokens, prefers-reduced-motion, and choreography that actually scales across a real codebase.
Why Animation Belongs in Your Token Layer
Most design systems nail color and spacing tokens in year one, then bolt animation on as an afterthought — usually as hardcoded 300ms ease strings scattered across 40 different component files. You end up with 12 slightly different easing curves and nobody can explain why the modal closes faster than it opens.
Tokens fix this. The idea is simple: name your timing and easing values the same way you'd name a color. --duration-fast: 150ms, --duration-base: 250ms, --duration-slow: 400ms. That's it. From there every component in your system pulls from the same source of truth, and changing a feel globally takes one line.
Honestly, the bigger win isn't consistency — it's that tokens give designers a vocabulary. Instead of a Figma comment saying "make this a bit snappier," you get "switch to duration-fast". That feedback loop changes how teams collaborate, especially across a mono-repo with multiple surfaces.
Worth noting: the design-tokens-guide article covers the broader tokens setup if you're starting from scratch. For animation specifically, you'll want a separate motion namespace to keep things clean — something like --motion-duration-* and --motion-easing-* rather than mixing with spacing or color prefixes.
Defining Motion Tokens: Duration, Easing, and Delay
You need three axes at minimum: duration, easing, and delay. Duration is the most obvious. Pick a scale — 4 or 5 stops is usually enough. Going beyond that and you're solving a problem you don't actually have.
:root {
/* Duration */
--motion-duration-instant: 80ms;
--motion-duration-fast: 150ms;
--motion-duration-base: 250ms;
--motion-duration-slow: 400ms;
--motion-duration-glacial: 700ms;
/* Easing */
--motion-ease-linear: linear;
--motion-ease-out: cubic-bezier(0.0, 0, 0.2, 1);
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--motion-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Delay */
--motion-delay-none: 0ms;
--motion-delay-short: 50ms;
--motion-delay-medium: 100ms;
--motion-delay-long: 200ms;
}The ease-out curve is your workhorse. Things entering the screen should decelerate — they're coming from somewhere and landing. ease-in is for exits. Never use plain linear for interface transitions unless you're doing something intentionally mechanical (think progress bars, loading indicators). The spring curve up there has a slight overshoot at cubic-bezier(0.34, 1.56, 0.64, 1) — perfect for things like a toggle switching on, or a button confirming a tap.
Delay tokens feel secondary but they're what makes choreography possible. A 50ms stagger between list items feels alive. No stagger and you get a wall of content that just appears. We'll come back to choreography in a later section — but plant the delay tokens now.
In practice, if you're using Tailwind, you can extend the theme to map these tokens. That way duration-fast becomes a utility class and you're not writing inline styles everywhere. The cubic-bezier-guide goes deep on the math behind these curves if you want to understand why these specific values feel the way they do.
prefers-reduced-motion: Non-Negotiable
If your design system doesn't handle prefers-reduced-motion, it's broken. Full stop. About 1 in 3 people with vestibular disorders report that motion on screen causes physical symptoms. That's not a niche edge case — that's a significant chunk of your users.
The pattern most teams use is wrong. They do this:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}That's a blunt instrument. It cuts all motion, including motion that actually aids comprehension — like a skeleton loader fading in, or focus rings appearing. The better approach is to keep transitions that communicate state change but remove transitions that are purely decorative. Build the intent into your token layer directly:
:root {
--motion-duration-base: 250ms;
--motion-duration-fast: 150ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration-base: 0ms;
--motion-duration-fast: 0ms;
--motion-duration-slow: 0ms;
/* Keep delay tokens — layout shifts still need staggering */
--motion-delay-short: 0ms;
}
}Now every component using those tokens automatically respects the user's preference without any extra code at the component level. One media query, system-wide coverage. You might also want a --motion-safe-fade token that maps to 250ms in the default state but 0ms under reduced motion — useful for things like tooltip fade-ins that need to disappear even when motion is off, just instantly rather than with a transition.
JavaScript-Side: Reading the Preference in Code
CSS handles most cases, but sometimes you're driving animation from JS — Framer Motion sequences, GSAP timelines, canvas-based effects. You need the preference available in code too.
const prefersReducedMotion = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Or as a React hook:
import { useState, useEffect } from 'react';
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return reduced;
}Then in a Framer Motion component, for example:
const reduced = useReducedMotion();
<motion.div
initial={{ opacity: 0, y: reduced ? 0 : 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduced ? 0 : 0.25,
ease: [0.0, 0, 0.2, 1],
}}
/>Look, this pattern also means you can test the reduced-motion state in Storybook or Playwright without having to mess with OS settings — just pass the value in as a prop. The wcag-accessibility-guide covers the broader a11y picture if you want to audit other areas at the same time.
Choreography: Making Components Move Together
Single-element transitions are easy. The hard part is making groups of elements feel like they're part of the same thought. That's choreography — and it's what separates UI that feels polished from UI that feels assembled.
The core rule: things that enter together should stagger slightly, things that exit together should compress. A modal dialog might fade in its backdrop at 0ms, slide the panel in at 80ms, then fade its content in at 160ms. Three separate transitions, one unified motion beat.
const STAGGER = 80; // maps to --motion-delay-short
const container = {
hidden: {},
visible: {
transition: { staggerChildren: STAGGER / 1000 },
},
};
const item = {
hidden: { opacity: 0, y: 12 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: [0.0, 0, 0.2, 1] },
},
};
function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((item, i) => (
<motion.li key={i} variants={item}>
{item}
</motion.li>
))}
</motion.ul>
);
}One more thing — exits are where most systems fall apart. People spend time on entrances and forget that the delete confirmation modal needs to leave gracefully. A good rule of thumb: exits should be 60-70% the duration of entrances. Entering takes 250ms, exiting takes 150ms. The user already saw it appear; they don't need to watch it disappear at the same pace.
Quick aside: if you're building animated backgrounds or hero sections — things like Aurora or particle fields — choreography at a macro level means syncing those ambient animations to page transitions. The aurora-background-react article shows how to coordinate those effects without them fighting the page-level transitions.
Semantic Motion Categories
Beyond raw duration and easing, well-structured design systems define motion *categories* — semantic names that describe the purpose of an animation, not just its timing. This is the layer that makes your system actually teachable to new team members.
Think of it like this: --motion-duration-base is primitive. --motion-enter, --motion-exit, --motion-feedback, --motion-ambient are semantic. The semantic tokens reference the primitives but carry intent:
:root {
/* Semantic motion roles */
--motion-enter: var(--motion-duration-base) var(--motion-ease-out);
--motion-exit: var(--motion-duration-fast) var(--motion-ease-in);
--motion-feedback: var(--motion-duration-fast) var(--motion-ease-in-out);
--motion-ambient: var(--motion-duration-glacial) var(--motion-ease-linear);
}
/* Usage */
.modal-backdrop {
transition: opacity var(--motion-enter);
}
.tooltip {
transition: opacity var(--motion-exit);
}
.button-press {
transition: transform var(--motion-feedback);
}This pattern means a designer can say "use the feedback motion on this micro-interaction" and the developer knows exactly what that means without looking up values. It's the same payoff you get from semantic color tokens — --color-surface-primary beats --color-white-100 because it carries meaning.
The motion-design-tokens article covers an alternative taxonomy if you want to compare approaches. There's no single right answer — the goal is that your team agrees on one vocabulary and sticks to it consistently across every component in the Empire UI library.
Testing and Documenting Animation in a Design System
Animation is the hardest thing to document and the easiest to break silently. A color change shows up in a screenshot diff. A timing regression doesn't.
The most practical approach is Storybook stories that render the animation in a controlled way, combined with prefers-reduced-motion stories alongside each animated component. You want to see both states side by side. If the reduced-motion version looks broken or confusing, the animation was doing functional work and you need to rethink it — not just disable it.
For regression testing, Playwright as of 2024 lets you emulate prefers-reduced-motion via browser context: await context.emulateMedia({ reducedMotion: 'reduce' }). Pair that with visual snapshot testing and you catch cases where someone accidentally hardcoded a duration instead of using a token.
Documentation-wise, treat motion like color — show the token name, show the value, show a live example. A static table of 250ms ease values tells you nothing. A side-by-side of "with animation" vs "reduced motion" tells you everything. Tools like the gradient generator and glassmorphism generator on Empire UI show what interactive preview docs can look like — that same principle applies to animation documentation. Make it interactive, make it toggleable.
One final thing: write a motion principles doc before you write a single token. Two or three sentences about *why* your system moves the way it does — "motion reinforces hierarchy, not personality" or "every transition must have a directional metaphor" — saves hours of token bikeshedding later. The principles drive the token names, not the other way around.
FAQ
You need at least three duration stops (fast, base, slow), two easing curves (ease-out for enter, ease-in for exit), and a reduced-motion override block. That covers 90% of real component needs without over-engineering the system.
Keep them in a separate motion or animation token file. Mixing motion with color and spacing works fine at small scale but becomes a maintenance headache fast — especially when you need to override motion globally for accessibility without touching the rest of the system.
Use a useReducedMotion hook to read the media query at runtime, then pass it as a condition into your animation config — zero duration and no positional transforms when it's true. Don't rely on CSS-only overrides when JS is driving the timeline.
Duration controls how long a single transition takes; delay controls when it starts. You mostly need delay tokens for choreography — staggering list items, sequencing modal layers, timing a loader after a button state change. Outside of choreography, delay is rarely the right tool.