EmpireUI
Get Pro
← Blog8 min read#motion#design tokens#animation

Motion Design Tokens: Systematising Easing, Duration and Delay

Stop hardcoding cubic-bezier values everywhere. Learn how to build a motion token system for easing, duration, and delay that scales across your entire design system.

Code editor screen showing animation timing and CSS variable declarations

Why Hardcoded Easing Values Are a Design Debt Bomb

You've seen this. Someone drops transition: all 0.3s ease on a button, someone else writes animation: fadeIn 250ms cubic-bezier(0.4, 0, 0.2, 1) on a modal, and six months later your codebase has 47 different duration values and nobody can explain why the tooltip fades out in 200ms but the drawer takes 350ms. It feels like nitpicking until you're trying to redesign the interaction layer and realise you can't change any single value without hunting through 80 files.

Honestly, most teams treat motion as an afterthought — something you sprinkle on at the end, component by component, with whatever feels right at the time. That works until it doesn't. The moment a designer says 'make everything feel 20% snappier' you're staring at a multi-day refactor instead of a one-line token update.

Design tokens for color and spacing are table stakes in 2026. Motion tokens are the same concept applied to time and physics: give your easing curves, durations, and delays named variables, store them in one place, and reference them everywhere. Change the value in one file, every animation in your product updates. That's it. That's the whole pitch.

Worth noting: the W3C Design Tokens spec (Community Group draft) explicitly includes duration and cubic-bezier as valid token types. The tooling around this — Style Dictionary, Theo, Cobalt — has matured enough that there's no good excuse not to do this anymore.

Defining Your Motion Token Vocabulary

Start with duration. You don't need 47 values — you need about five. Think in terms of what the animation is doing, not how long it takes. A --duration-instant at around 80ms handles micro-feedback like button press states. --duration-fast at 150ms covers most hover effects and small state changes. --duration-base at 250ms is your workhorse — modals, dropdowns, toasts. --duration-slow at 400ms for page-level transitions and panels. --duration-deliberate at 600ms+ for onboarding flows or hero animations where you want the user to notice.

:root {
  /* Duration scale */
  --duration-instant:    80ms;
  --duration-fast:      150ms;
  --duration-base:      250ms;
  --duration-slow:      400ms;
  --duration-deliberate: 600ms;

  /* Easing scale */
  --ease-linear:        linear;
  --ease-standard:      cubic-bezier(0.4, 0, 0.2, 1);  /* Material standard */
  --ease-decelerate:    cubic-bezier(0, 0, 0.2, 1);    /* entering elements */
  --ease-accelerate:    cubic-bezier(0.4, 0, 1, 1);    /* exiting elements */
  --ease-spring:        cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */
  --ease-bounce:        cubic-bezier(0.68, -0.55, 0.27, 1.55);

  /* Delay scale */
  --delay-none:    0ms;
  --delay-short:   50ms;
  --delay-base:    100ms;
  --delay-stagger: 40ms;  /* multiply by index for staggered lists */
}

Easing is where teams get philosophical. The short version: --ease-decelerate for anything entering the screen (element starts fast, settles gently — feels like it arrived from somewhere). --ease-accelerate for exits (starts slow, leaves quickly — gets out of the way). --ease-standard for in-place state changes. --ease-spring for playful interactions where a tiny overshoot adds life. Do not use ease-in-out directly — that's what --ease-standard is for, and naming it gives it meaning.

Delay tokens are the most underrated part of this system. A flat --delay-stagger: 40ms means you can animate a list of 10 items with animation-delay: calc(var(--delay-stagger) * var(--index)) and never touch the CSS again when the list grows. Quick aside: stagger delays above 60ms start to feel sluggish on lists longer than 8 items — keep it tight.

In practice, you'll also want a --motion-reduced flag so you can hook into prefers-reduced-motion at the token level rather than scattering @media queries everywhere. More on that in the accessibility section.

Wiring Tokens into CSS, Tailwind, and JavaScript

CSS custom properties are the foundation. Define your tokens in :root, then reference them directly in any property that accepts a time or easing value. This works in transition, animation-duration, animation-timing-function, and — if you're using the Web Animations API — you can pass them in as strings via getComputedStyle.

/* Usage in component CSS */
.modal {
  transform: translateY(16px);
  opacity: 0;
  transition:
    transform var(--duration-base) var(--ease-decelerate),
    opacity   var(--duration-fast)  var(--ease-standard);
}

.modal[data-state='open'] {
  transform: translateY(0);
  opacity: 1;
}

/* Staggered list */
.list-item {
  animation: fadeSlideUp var(--duration-base) var(--ease-decelerate) both;
  animation-delay: calc(var(--delay-stagger) * var(--index));
}

For Tailwind users, extend your tailwind.config.ts with a transitionTimingFunction and transitionDuration extension that maps to your CSS vars. You can't use CSS custom properties directly as Tailwind class values (they're not known at build time), but you can define semantic utility names that output the right var() calls:

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  theme: {
    extend: {
      transitionDuration: {
        instant:    'var(--duration-instant)',
        fast:       'var(--duration-fast)',
        base:       'var(--duration-base)',
        slow:       'var(--duration-slow)',
        deliberate: 'var(--duration-deliberate)',
      },
      transitionTimingFunction: {
        standard:   'var(--ease-standard)',
        decelerate: 'var(--ease-decelerate)',
        accelerate: 'var(--ease-accelerate)',
        spring:     'var(--ease-spring)',
      },
    },
  },
} satisfies Config;

Now you can write className="transition-all duration-base ease-decelerate" and it references your token, not a hardcoded value. Change the root variable once, every component using duration-base updates. That's the whole point.

If you're using Framer Motion (which powers a lot of the animation work in Empire UI), you can pull tokens into your JS transition objects using getComputedStyle(document.documentElement).getPropertyValue('--duration-base'). Wrap that in a small utility and you've bridged the CSS-JS gap cleanly without duplicating values in two places.

The Physics of Good Easing: What Each Curve Actually Does

A cubic-bezier curve takes four numbers: the x and y coordinates of two control points on a 0–1 timeline. The curve describes velocity over time — steep early means fast start, steep late means fast finish. cubic-bezier(0.4, 0, 0.2, 1) — Material Design's standard curve since version 2.0 (2014) — starts slightly fast and decelerates gently into its resting state. It's the safe default for 80% of UI transitions and it looks good because it mirrors how physical objects decelerate when they stop.

Look, the spring curve is where a lot of developers either fall in love with motion or overdo it. cubic-bezier(0.34, 1.56, 0.64, 1) — the y value exceeds 1.0, which is how you get overshoot in CSS without keyframes. The element goes slightly past its target, then settles back. At 250ms on a card or button, this reads as playful and alive. At 600ms on a page transition, it reads as a bug.

// Motion token map for use with Framer Motion
export const motionTokens = {
  duration: {
    instant:    0.08,
    fast:       0.15,
    base:       0.25,
    slow:       0.40,
    deliberate: 0.60,
  },
  ease: {
    standard:   [0.4, 0, 0.2, 1],
    decelerate: [0, 0, 0.2, 1],
    accelerate: [0.4, 0, 1, 1],
    spring:     [0.34, 1.56, 0.64, 1],
  },
} as const;

// Usage
<motion.div
  initial={{ opacity: 0, y: 16 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{
    duration: motionTokens.duration.base,
    ease: motionTokens.ease.decelerate,
  }}
/>

Worth noting: Framer Motion uses seconds, CSS uses milliseconds. If you're sharing tokens between both, either store seconds and multiply by 1000 for your CSS vars, or store milliseconds and divide when passing to Framer. Pick one convention and document it in your token file — this trips up every team at least once.

Accessibility: Reduced Motion Without Gutting Your UI

Here's the thing most tutorials skip — prefers-reduced-motion: reduce doesn't mean 'remove all animation.' It means 'reduce motion that could cause vestibular issues.' Fades, color transitions, and subtle scale changes are generally fine. Large translations, spinning elements, parallax effects, and anything that moves across significant screen distance is what you should kill.

The cleanest pattern is to override duration tokens at the :root level inside the media query. This propagates through your entire system automatically — no hunting for individual components:

@media (prefers-reduced-motion: reduce) {
  :root {
    --duration-instant:    0ms;
    --duration-fast:       0ms;
    --duration-base:       0ms;
    --duration-slow:       0ms;
    --duration-deliberate: 0ms;
    /* Keep easing vars — they don't matter at 0ms duration */
  }
}

That one block makes every animation in your system instant for affected users. No exceptions, no forgotten components. If you want to preserve opacity fades (acceptable for most users with motion sensitivity), you can keep --duration-fast: 150ms and only zero out the larger durations. The decision is yours, but make it at the token level, not per-component.

Empire UI's animation components — including the aurora backgrounds and the cursor effects over at cursors — all respect prefers-reduced-motion out of the box. You can check the implementation pattern there if you want to see how we handle the graceful degradation without making disabled animations look broken.

Scaling Motion Tokens Across a Design System

Once you've got five durations, six easing curves, and a couple of delay values, the next question is how they map to component roles. This is where a semantic naming layer pays off — instead of just having --duration-base, you also have --duration-tooltip-show that maps *to* --duration-base. When your designer decides tooltips need to feel snappier in 2027, you change one semantic alias, not every tooltip in the codebase.

:root {
  /* Semantic aliases — map to primitive tokens */
  --duration-tooltip-show:  var(--duration-fast);
  --duration-tooltip-hide:  var(--duration-instant);
  --duration-modal-enter:   var(--duration-base);
  --duration-modal-exit:    var(--duration-fast);
  --duration-page-enter:    var(--duration-slow);
  --duration-toast-enter:   var(--duration-fast);
  --duration-toast-exit:    var(--duration-base);

  --ease-tooltip:  var(--ease-standard);
  --ease-modal:    var(--ease-decelerate);
  --ease-page:     var(--ease-decelerate);
}

Two layers: primitive tokens (the raw values) and semantic tokens (the intent). Primitives live in your global token file. Semantics live closer to your component layer, or in a separate motion-semantics.css. Tools like Style Dictionary let you express this in JSON and generate both CSS custom properties and JS exports simultaneously, which is worth the setup cost on any team larger than two developers.

If you're building on top of Empire UI, this pairs naturally with the gradient generator and box shadow generator workflows — you're already thinking in design tokens for visual properties, motion is just the temporal equivalent. Same mental model, same single-source-of-truth principle.

One more thing — document your motion tokens with examples. A Storybook story showing all five durations side-by-side on the same animation takes 20 minutes to write and saves hours of Slack messages asking 'which timing should I use for this thing.' The token names are half the value; the visible reference is the other half.

Putting It All Together: A Real Implementation Checklist

Here's how to actually ship this in an existing codebase rather than greenfield. Don't try to migrate everything at once. Start by adding the token definitions to your global CSS file — this is additive, nothing breaks. Then pick one high-traffic component (modal, drawer, or toast) and migrate it to use the new vars. Get that in production, verify it looks right, then roll out to the rest of the system over a few sprints.

Your checklist: (1) define primitive duration and easing tokens in :root; (2) add semantic aliases for your five or six most common component patterns; (3) extend tailwind.config.ts with the custom utility names; (4) create a JS/TS export of the same values in seconds for Framer Motion or WAAPI; (5) add the prefers-reduced-motion override block; (6) write a Storybook page or simple HTML file showing all durations side-by-side; (7) document which semantic token maps to which component in your design system docs.

That's six to eight hours of work that pays back every time a designer requests a motion audit or you onboard a new developer. And if you're building expressive UIs — the kind you'd find in Empire UI's style hubs or the cyberpunk and vaporwave theme collections — consistent, systematic motion is what separates 'this feels polished' from 'this feels like a prototype.'

The discipline is the same as any other token system. One source of truth. Semantic names over magic numbers. And an escape hatch for users who need the motion dialled back to zero. Get those three things right and your animation layer will stay maintainable no matter how many components you ship.

FAQ

What's the difference between primitive and semantic motion tokens?

Primitive tokens are raw values (--duration-base: 250ms). Semantic tokens are intent-named aliases that point to primitives (--duration-modal-enter: var(--duration-base)). Primitives define your scale; semantics define where each value is used so you can remap them independently.

Can I use CSS custom properties for animation timing with Framer Motion?

Not directly — Framer Motion expects JavaScript values (seconds as numbers, easing arrays or strings). Use getComputedStyle(document.documentElement).getPropertyValue('--duration-base') to read the CSS var at runtime, or maintain a parallel JS export of the same token values.

How many duration tokens do I actually need?

Five covers almost every case: instant (80ms), fast (150ms), base (250ms), slow (400ms), deliberate (600ms). Adding more just creates decision paralysis. Start with five and only add a sixth when you genuinely can't map a use case to any existing tier.

Does setting durations to 0ms in prefers-reduced-motion break layout?

No. At 0ms the animation skips instantly to its end state — the final position, opacity, and size are all correct. The only time this causes visual issues is if your animation is also doing layout work mid-transition, which is a separate problem to fix regardless.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Design Tokens in 2026: From Figma Variables to CSS Custom PropertiesAnimation in Design Systems: Tokens, Reduced Motion, ChoreographyTailwind Custom Colors: CSS Variables, OKLCH, and Design TokensAdvanced CSS Custom Properties: @property, Animatable Tokens