Animation in Design Systems: Tokens, Curves, Duration Scale
Animation tokens, easing curves, and duration scales explained for React design systems. Stop hard-coding transitions and start building motion that actually scales.
Animation Tokens Are Not Optional
Honestly, most design systems treat animation as an afterthought — a sprinkle of transition: all 0.3s ease copy-pasted across fifty components and called done. That's not a motion system. That's chaos with a duration.
Animation tokens are the same idea as color tokens or spacing tokens. You name a value once, you use the name everywhere, and when your lead designer says 'make everything feel snappier', you change one number instead of grepping through 200 files. If you've already read our piece on spacing systems in CSS, you know exactly how much time a token-first approach saves when requirements shift.
A minimal motion token set needs at least three things: a duration scale, an easing library, and a delay scale. Without all three, you'll end up with components that animate at different speeds with different curves — and users notice that inconsistency even when they can't articulate it.
Building a Duration Scale That Makes Sense
Duration scales work best when they map to physical intuition. Small elements move fast. Large elements move slow. A tooltip appearing covers a tiny area; a full-page modal expanding covers a huge one. If they both animate at 200ms, something feels wrong.
Here's a scale that's served well across multiple production systems. It borrows from IBM Carbon's approach but tightens the upper end:
:root {
--duration-instant: 50ms; /* state changes, toggles */
--duration-fast: 100ms; /* tooltips, badges, chips */
--duration-base: 200ms; /* buttons, inputs, dropdowns */
--duration-moderate: 300ms; /* cards, panels, drawers */
--duration-slow: 500ms; /* page transitions, modals */
--duration-deliberate: 800ms; /* onboarding, hero reveals */
}Notice there's nothing above 800ms. Anything slower than that stops feeling like UI and starts feeling like waiting. Your users didn't open your app to watch things move — they opened it to get something done.
Easing Curves: The Part Everyone Gets Wrong
Linear easing is almost never right. It feels mechanical. But ease — CSS's default — isn't much better because it's not designed for any specific interaction type. It's a compromise that satisfies nobody.
The physics-based mental model is the useful one here. Things in the real world accelerate and decelerate. An element entering the screen should start fast and slow down as it settles (decelerate). An element leaving should start slow and exit quickly (accelerate). An element moving within the screen — repositioning — should do both (standard ease-in-out).
:root {
/* Entering: starts fast, ends slow — element decelerates into place */
--ease-decelerate: cubic-bezier(0.0, 0.0, 0.2, 1);
/* Exiting: starts slow, ends fast — element accelerates away */
--ease-accelerate: cubic-bezier(0.4, 0.0, 1, 1);
/* Repositioning: standard ease-in-out */
--ease-standard: cubic-bezier(0.4, 0.0, 0.2, 1);
/* Expressive / spring-like: slight overshoot */
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
}The --ease-spring curve is the one that makes things feel alive. Use it sparingly — it's great for success states, confirmation checkmarks, and 'item added to cart' moments. Use it everywhere and it starts to feel like a cartoon.
Wiring Tokens into a React Design System
CSS custom properties are the right delivery mechanism for motion tokens because they're inherited, overridable per-component, and work with every styling approach — Tailwind, CSS Modules, styled-components, whatever you're using this week. If you're working with Tailwind vs CSS Modules, this pattern bridges both worlds cleanly.
For Tailwind v4.0.2, you can map your CSS custom properties directly into the theme using the new @theme block. Here's how a complete motion token integration looks in a component:
// motion.ts — your single source of truth
export const motion = {
duration: {
instant: 'var(--duration-instant)',
fast: 'var(--duration-fast)',
base: 'var(--duration-base)',
moderate: 'var(--duration-moderate)',
slow: 'var(--duration-slow)',
},
easing: {
decelerate: 'var(--ease-decelerate)',
accelerate: 'var(--ease-accelerate)',
standard: 'var(--ease-standard)',
spring: 'var(--ease-spring)',
},
} as const;
// Usage in a component
function Drawer({ open }: { open: boolean }) {
return (
<div
style={{
transform: open ? 'translateX(0)' : 'translateX(-100%)',
transition: `transform ${motion.duration.moderate} ${motion.easing.decelerate}`,
}}
>
{/* content */}
</div>
);
}That motion object becomes the contract between design and engineering. When the design file says 'moderate duration, decelerate curve', engineers know exactly what to reach for. No more guessing. No more transition: all 300ms ease-in-out scattered like confetti.
Respecting prefers-reduced-motion
Here's a hard rule: if you're building a component library and you don't handle prefers-reduced-motion, you're shipping an accessibility bug. Full stop. Our WCAG accessibility guide covers this in depth, but the motion-specific implementation deserves its own treatment.
The pattern is straightforward. You define reduced-motion overrides in a single media query block, ideally right next to your token definitions so it's impossible to miss:
@media (prefers-reduced-motion: reduce) {
:root {
--duration-instant: 0ms;
--duration-fast: 0ms;
--duration-base: 0ms;
--duration-moderate: 0ms;
--duration-slow: 0ms;
--duration-deliberate: 0ms;
}
}Setting durations to 0ms is intentional, not lazy. It means the state change still happens — the element still moves from A to B — it just happens without animation. This is correct behavior. Some users don't want motion disabled entirely; they want it instant. Setting to 0ms respects that. And because all your components consume the token rather than hard-coded values, this one block covers your entire system.
Composing Animations With Staggered Delays
A list of cards appearing simultaneously looks like a flash. The same list appearing with 60ms between each card looks like it's loading with intention. Stagger delays are the difference, and they're easy to token-ize.
The trick is making the stagger value a multiplier, not a fixed offset. This way, a list of 3 items and a list of 10 items both feel natural rather than one feeling snappy and the other feeling like it's counting to ten out loud.
const STAGGER_BASE = 60; // ms — tune this per component
function AnimatedList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, i) => (
<li
key={item}
style={{
animationDelay: `${i * STAGGER_BASE}ms`,
animationDuration: 'var(--duration-moderate)',
animationTimingFunction: 'var(--ease-decelerate)',
animationFillMode: 'both',
animationName: 'fadeSlideIn',
}}
>
{item}
</li>
))}
</ul>
);
}Cap your total stagger time. If you have 20 items and each delays by 60ms, the last item doesn't appear until 1200ms after the first. That's too long. A sensible rule: total stagger time shouldn't exceed --duration-slow (500ms). Divide that by your item count to get your stagger multiplier dynamically.
Animation Tokens in Your Storybook and Figma Handoff
Defining tokens in CSS is only half the job. They need to live in your documentation too, or they'll be invisible to the designers updating your Figma to React workflow. Storybook is the natural home for a motion token showcase.
Build a dedicated 'Motion' story that renders each token combination in isolation. A row for each duration token, a column for each easing curve, and an interactive preview that lets you trigger the animation on click. Designers can look at this story and immediately validate whether what's in Figma matches what's in code. If you're already using Storybook for component library documentation, adding a motion story takes about two hours and pays off every sprint.
In Figma, Variables (available since Figma 2023) can store motion values. Map your --duration-base: 200ms and --ease-standard to Figma Variables with matching names. When a designer applies a motion variable to a prototype interaction, the name matches the CSS token. The handoff becomes a lookup, not a translation. That's the whole point of a token-based system — remove the interpretation layer between design and code.
When Not to Animate
Why do so many interfaces feel heavy? Not because of too little animation — because of too much. Every border that color-transitions on hover. Every button that scales on press. Every dropdown that slides AND fades AND blurs simultaneously. It adds up.
A good heuristic: animate position and opacity. Be cautious with scale. Avoid animating width, height, padding, or margin directly — these trigger layout recalculations on every frame and will tank your performance on mid-range Android devices. Use transform: scaleX() instead of animating width. Use transform: translateY() and opacity instead of animating max-height.
The other filter: only animate when the animation communicates something. A button pressing should feel like pressing because it communicates physical interaction. A sidebar sliding in communicates where the content is coming from. A random sparkle effect on a form label communicates nothing except that someone had too much free time. If you can't explain what the animation tells the user in one sentence, cut it.
FAQ
Use transition for state changes between two known values — a button going from its default background to its hover background. Use @keyframes + animation when you need more than two steps, looping behavior, or when the starting state isn't the element's natural style (like an entrance animation where you're animating from an off-screen position). In practice, transitions cover 80% of component UI needs.
Both, ideally. CSS custom properties are the source of truth because they work in stylesheets, inline styles, and CSS-in-JS. A JavaScript motion object that references the CSS variable names (e.g., 'var(--duration-base)') gives you type safety and autocomplete in your component code without duplicating the actual values. Changes to the CSS property cascade everywhere automatically.
Motion tokens generally don't need to change between themes — durations and easing curves are theme-agnostic. The exception is opacity-based animations where your start/end values might differ on dark vs. light backgrounds. Keep motion tokens in :root regardless of the color scheme, and only override them if you have a specific reason (like a 'high contrast' mode where you reduce motion intensity).
Yes — 60fps (roughly 16.7ms per frame) is still the browser standard for smooth animation. In Chrome DevTools, open the Performance panel, hit Record, trigger your animation, then look at the Frames section. Any frame taking more than 16ms will show as a red bar. The quick fix for most jank is switching to transform and opacity only — these are composited by the browser and don't block the main thread.
Set pointer-events: none on items that haven't finished animating yet, or use animationFillMode: 'both' so items are already in their final visible state before the animation kicks in (reducing the jarring effect of items appearing mid-scroll). Better still, only trigger entrance animations for items in the viewport using an IntersectionObserver — elements the user never scrolls to never animate at all.
Yes. In Tailwind v4.0.2, you can use [transition-duration:var(--duration-base)] as an arbitrary value class, or define utilities in the @theme block. For example: --animate-duration-base: var(--duration-base) in your @theme block makes it available as a CSS property to arbitrary utilities. For complex motion, dropping down to inline style props that reference your CSS variables is often cleaner than fighting the utility class system.