← Writing
5 min read

Building a motion token system

Colour and spacing get tokenised; motion almost never does. A small set of durations and curves, named by intent, and the rules that keep them from multiplying.

  • design-systems
  • technique

Every design system tokenises colour. Most tokenise spacing and type. Very few tokenise motion, which is why so many otherwise-coherent products feel assembled rather than designed the moment anything moves.

The symptom is easy to spot. Grep a mature codebase for transition: and you will find 200ms, 250ms, 0.3s, 180ms, and ease, ease-out, and four hand-written cubic-beziers. None of them is wrong on its own, and none of them agrees with each other. Each was a reasonable local decision. Together they read as noise.

What a motion token actually is

Three things, and the third is the one people skip.

Durations: a small numeric scale. Easings: a small set of curves. Semantic pairings: named combinations that say what kind of motion this is, not what it is made of.

That last one carries the weight. A token called --duration-200 is barely better than writing 200ms; it renames the value without deciding anything. A token called --motion-exit encodes a judgement, and that judgement is the actual product of the system.

A scale that fits on one screen

Start smaller than feels comfortable. Six durations and four curves cover almost everything.

:root {
  /* Durations */
  --duration-instant: 100ms;  /* hover, focus, colour */
  --duration-fast:    150ms;  /* small state changes */
  --duration-base:    250ms;  /* the default */
  --duration-slow:    350ms;  /* sheets, drawers */
  --duration-slower:  500ms;  /* full-screen, page-level */

  /* Easings */
  --ease-out:    cubic-bezier(0.33, 1, 0.68, 1);
  --ease-out-strong: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in:     cubic-bezier(0.32, 0, 0.67, 0);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}

Then the semantic layer, which is where the system stops being a list and starts being opinionated:

:root {
  --motion-enter:  var(--duration-base) var(--ease-out);
  --motion-exit:   var(--duration-fast) var(--ease-in);
  --motion-move:   var(--duration-base) var(--ease-in-out);
  --motion-hover:  var(--duration-instant) var(--ease-out);
  --motion-sheet:  var(--duration-slow) var(--ease-out-strong);
}

Now a component reads as intent:

.dropdown { transition: opacity var(--motion-enter), transform var(--motion-enter); }
.dropdown[hidden] { transition: opacity var(--motion-exit), transform var(--motion-exit); }

Nobody writing that line has to decide anything, which is the point. The decisions were made once, by whoever set the tokens.

Encode the asymmetry

The single most valuable thing a motion system can enforce is that exits are shorter than entrances. Left to per-component judgement this is the first thing to get lost. Symmetry is the intuitive choice, and it is wrong. --motion-exit at 150ms against --motion-enter at 250ms bakes the rule in, so getting it right is the path of least resistance.

The reasoning is in how long a UI animation should take: an entrance is informative, an exit is latency between a decision and its acknowledgement.

Scale duration with distance, not with importance

The most common mistake once a system exists is treating the duration scale as a significance scale, where important things get --duration-slower, minor things get --duration-fast. That is backwards.

Duration should track how far the thing travels and how much of the screen it occupies. A large sheet needs longer than a tooltip because it sweeps more visual field, not because it matters more. An important-but-small state change should still be fast.

Naming helps here. --duration-sheet and --duration-tooltip invite the right reasoning; --duration-major and --duration-minor invite the wrong one.

Keeping the set from growing

Token systems fail by accretion. Someone needs 220ms, adds --duration-base-plus, and within a year the scale has fourteen entries and no meaning.

Three rules that hold the line:

  • New tokens need a reason that is not “this felt slightly better.” Perceptual thresholds are wide. The difference between 250ms and 220ms is not something users detect, and it is definitely not worth a token.
  • Round to the scale, and if nothing fits, question the animation rather than the scale. Needing a value between two steps usually means the motion is doing something the system did not anticipate. That is worth understanding before you encode it.
  • Semantic tokens can grow; primitives should not. Adding --motion-toast composed of existing values is cheap and clarifying. Adding --duration-270 is not.

Reduced motion belongs in the tokens

If prefers-reduced-motion is handled per component, it will be handled inconsistently. Put it at the token layer and every consumer inherits it:

@media (prefers-reduced-motion: reduce) {
  :root {
    --duration-instant: 1ms;
    --duration-fast:    1ms;
    --duration-base:    1ms;
    --duration-slow:    1ms;
    --duration-slower:  1ms;
  }
}

This is a blunt instrument and a good floor: it neutralises everything built on the scale in one place. The better treatment, component by component, keeps opacity transitions and drops only the movement. But a system that is correct by default beats one that is ideal in theory and applied unevenly.

Note it only works if components actually use the tokens. Any hardcoded transition: transform 300ms escapes it silently, which is a reasonable thing to lint for.

Where to put them

CSS custom properties are the right home: they cascade, they can be overridden per component or per theme, they need no build step, and JavaScript can read them with getComputedStyle when an animation has to be driven imperatively. That last point matters. It keeps a JS-driven animation and a CSS one using the same numbers instead of drifting apart.

If your motion lives mostly in a JS library, export the same values from one module and have the CSS custom properties and the JS config derive from a single source. Two lists that must agree by hand will eventually disagree.

The test

The system is working when a new component is easier to animate correctly than incorrectly, when reaching for var(--motion-enter) is less effort than typing 250ms ease-out.

If people are still writing raw values, the token set is either too large to remember or missing the case they actually have. Both are fixable, and both are more useful signals than a lint rule.

Coherence is the goal, not expressiveness. A product where everything moves the same considered way reads as designed. That is appeal in the classical sense. Not charm, but a vocabulary applied consistently.