DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Motion Design Tokens That Actually Compose: Durations, Easings, Choreography

  • Three-tier motion tokens: primitives like --ms-200 and --ease-out-expo feed semantic and component layers, never the other way around

  • Stagger groups with --stagger-step calc() unlock orchestrated entrances without per-item timing math

  • animation-timeline: scroll() composes with the same easing primitives, so scroll choreography reuses the system

  • prefers-reduced-motion is a token override, not a global kill switch, so brand timing survives accessibility

  • Safari needs the -webkit-animation-timeline prefix and a fallback duration, otherwise scroll-driven animations silently die

Motion is the part of a design system most teams skip until it's already broken. By the time someone asks for a "snappier hover," there are 47 different easing curves shipping in production and three definitions of "fast." I rebuilt the motion layer for raxxo.shop last month around three token tiers. Here is what actually composed and what didn't.

Tier one: motion primitives

The base layer holds raw values with zero opinion about what they're for. Two groups: durations and easings.


:root {
  /* durations */
  --ms-50:   50ms;
  --ms-100:  100ms;
  --ms-150:  150ms;
  --ms-200:  200ms;
  --ms-300:  300ms;
  --ms-500:  500ms;
  --ms-800:  800ms;

  /* easings */
  --ease-linear:    linear;
  --ease-in:        cubic-bezier(0.4, 0, 1, 1);
  --ease-out:       cubic-bezier(0, 0, 0.2, 1);
  --ease-in-out:    cubic-bezier(0.4, 0, 0.2, 1);
  --ease-snap:      cubic-bezier(0.2, 0.9, 0.1, 1);
  --ease-out-expo:  cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-back:  cubic-bezier(0.34, 1.56, 0.64, 1);
}

Enter fullscreen mode Exit fullscreen mode

Two rules I enforce here. The duration scale is geometric-ish, not linear. 50, 100, 150, 200, 300, 500, 800. You never need 250ms. If you think you do, pick 200 or 300 and move on. Decision fatigue from a 50-step ladder is a real cost, and nobody can tell 240ms from 260ms in a hover.

The easings have plain-English names. --ease-snap is what you reach for when something arrives, settles, and shouldn't bounce. --ease-out-back is the one that overshoots a touch, perfect for confirmation chips. Curve names tied to behaviour read better in code than --ease-curve-7. Six months later I still know what --ease-snap does without checking the bezier values.

Primitives never ship to components directly. That's the rule. If a button's transition references --ms-200, you've leaked a primitive into the component layer and now you can't change motion language without grepping the entire codebase.

Tier two: semantic motion tokens

Semantic tokens describe intent, not numbers. They're the only thing components are allowed to consume. Here's the slice from raxxo.shop right now.


:root {
  /* enter / exit */
  --motion-toast-in:    var(--ms-200) var(--ease-out-expo);
  --motion-toast-out:   var(--ms-150) var(--ease-in);
  --motion-modal-in:    var(--ms-300) var(--ease-out-expo);
  --motion-modal-out:   var(--ms-200) var(--ease-in);
  --motion-popover-in:  var(--ms-150) var(--ease-snap);

  /* feedback */
  --motion-pressed:     var(--ms-100) var(--ease-out);
  --motion-hover:       var(--ms-150) var(--ease-out);
  --motion-focus-ring:  var(--ms-100) var(--ease-out);

  /* page-level */
  --motion-page-in:     var(--ms-500) var(--ease-out-expo);
  --motion-route-cross: var(--ms-300) var(--ease-in-out);
}

Enter fullscreen mode Exit fullscreen mode

The pattern: packed into a single custom property. Every CSS animation can grab one variable and be done. The transitions read like sentences:


.toast {
  transition: opacity var(--motion-toast-in), transform var(--motion-toast-in);
}
.toast[data-leaving="true"] {
  transition: opacity var(--motion-toast-out), transform var(--motion-toast-out);
}

Enter fullscreen mode Exit fullscreen mode

Two things to watch. First, exits are usually faster than entrances. People don't want to wait for things to leave. 150ms out, 200-300ms in. That's a baked-in habit at this layer, so individual components don't have to think about it.

Second, the semantic layer is where you handle direction asymmetry. A modal slides up on entry but fades on exit. That belongs here as --motion-modal-in-transform and --motion-modal-out-opacity, not in the component CSS. Components stay declarative.

For deeper context on building semantic layers on top of primitives, Tailwind v4 Theme: Design Tokens That Actually Scale walks through the same idea for color and typography via the @theme directive.

Tier three: component motion tokens

Component tokens are aliases. They exist for one reason: a component should never reach above its layer. A button references --motion-button-hover, not --motion-hover, even if the value is identical today.


.button {
  --motion-button-hover:   var(--motion-hover);
  --motion-button-pressed: var(--motion-pressed);
  --motion-button-loading: var(--ms-800) var(--ease-linear);

  transition: background var(--motion-button-hover),
              transform var(--motion-button-pressed);
}
.button:hover  { transform: translateY(-1px); }
.button:active { transform: translateY(0); }

Enter fullscreen mode Exit fullscreen mode

Looks redundant. It isn't. The day a designer asks for snappier buttons without touching anything else, I change one line. Without component tokens, that change either touches a hundred places or punches through the whole hover language and breaks links, cards, and chips with it.

The override path is also where you handle the weird stuff. The "loading spinner" duration is 800ms, not because that's the hover language of the system, but because spinners need that. Component tokens absorb the exceptions so the semantic layer stays clean.

Composition: stagger, sequence, scroll

The reason this whole structure exists is composition. Three patterns that pay rent.

Stagger groups. Every list animation uses the same primitive plus a calc:


.list {
  --stagger-step: var(--ms-50);
}
.list > *:nth-child(n) {
  animation: enter var(--motion-page-in) both;
  animation-delay: calc(var(--stagger-step) * var(--i, 0));
}

Enter fullscreen mode Exit fullscreen mode

Then on the JSX side I just set style={{ "--i": index }}. One declaration, infinite list lengths, perfectly choreographed. Change the step from 50ms to 30ms and every staggered list in the app speeds up together.

Sequenced entries. When two things should arrive in order, the second uses the first's duration as a delay:


.hero-headline    { animation: enter var(--motion-page-in) both; }
.hero-subtext     { animation: enter var(--motion-page-in) var(--ms-150) both; }
.hero-cta         { animation: enter var(--motion-page-in) var(--ms-300) both; }

Enter fullscreen mode Exit fullscreen mode

The delays are primitives, not magic numbers. Three lines. The whole hero entrance reads down the file in the order the user sees it.

Scroll-driven animation. The 2024 animation-timeline lands the same composition story for scroll:


.parallax-card {
  animation: lift linear;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}
@keyframes lift {
  from { transform: translateY(40px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}

Enter fullscreen mode Exit fullscreen mode

The keyframes are the same ones I use for click-driven entrances. The timeline is the only thing that changes. That's composition working: one set of motions, multiple drivers.

If you're new to scroll-driven animation, I covered the ergonomics in CSS Anchor Positioning Is Production-Ready: 5 Patterns which uses the same browser-feature shape (works in Chromium, partial in Safari, polyfill if you need Firefox).

What breaks: reduced motion and Safari

Two honest gotchas before you ship this.

prefers-reduced-motion is not a kill switch. If you set every duration to 0, you lose the timing language that tells users state changed. Toasts that pop in instantly look like bugs. The pattern I use is overriding the semantic tier with shorter, gentler tokens, not zero:


@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-toast-in:    var(--ms-100) var(--ease-out);
    --motion-modal-in:    var(--ms-100) var(--ease-out);
    --motion-page-in:     var(--ms-100) var(--ease-linear);
    --stagger-step:       0ms;
  }
}

Enter fullscreen mode Exit fullscreen mode

Stagger collapses to zero. Easings flatten to linear or out. Durations cut to 100ms. Nothing flashes, nothing crawls. Test it with macOS System Settings > Accessibility > Display > Reduce Motion and watch the whole app shift mood without any per-component changes.

Safari is the other one. animation-timeline: scroll() has been in Chrome since 2023 and Safari is still partial as of 2026. The defensive pattern:


.parallax-card {
  /* fallback for Safari + older browsers */
  animation: lift var(--ms-500) var(--ease-out-expo) both;

  /* progressive enhancement */
  @supports (animation-timeline: view()) {
    animation: lift linear;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

Enter fullscreen mode Exit fullscreen mode

Browsers that don't get scroll-driven still get a clean entrance animation on load. The semantic tokens make the fallback a one-liner, because the easing and duration are already defined for the rest of the system.

The other Safari trap: variable-driven animation-delay with calc() works fine, but if you nest var() inside calc() inside animation shorthand, Safari 17 sometimes drops the delay silently. Use the long-form animation-delay: property instead of cramming it into the shorthand. I lost half a day to that one.

Bottom line

Three tiers, two rules, one habit. Primitives never touch components. Components never touch primitives. The semantic layer is the contract.

Most motion problems in design systems aren't about easing curves. They're about the semantic layer being missing. Once you name --motion-toast-in instead of writing 200ms ease-out everywhere, redesigning motion language becomes a 10-line diff. Without it, every motion change is a sweep.

If you're starting fresh, the order I'd build in: primitive durations and easings on day one, three or four semantic tokens for the most common moves (hover, modal-in, page-in, toast-in), and component tokens only when a component genuinely needs to override the semantic default. Don't pre-create 40 component tokens. Add them when the override happens.

I keep the full motion token file in the RAXXO Lab, alongside the rest of the design tokens I lean on to ship products without re-bikeshedding the basics every project. Steal anything that's useful.

Top comments (0)