Motion Design Systems & Tokens
Part of Core CSS Animation Fundamentals.
Most codebases do not have a motion system; they have motion values. A search turns up 200ms, .2s, 250ms, 0.3s, 300ms and 350ms, alongside ease, ease-out, three different cubic-bezier() curves copied from blog posts and one spring someone tuned by eye. Each value was reasonable in isolation. Together they make an interface where menus, dialogs, toasts and tabs all move with slightly different personalities, where a brand refresh cannot change the “feel” without touching hundreds of files, and where reduced motion is handled component by component — or not at all.
A motion system replaces those values with a small vocabulary. Tokens give durations, easings and distances names. Semantic roles say what a motion is for — entering, exiting, moving, emphasising — so components pick intent rather than numbers. Choreography rules decide how multiple elements move together. And because every animation reads from the same tokens, reduced-motion handling and performance limits can be enforced in one place. This topic covers how to build that layer in CSS and keep it honest.
Concept definition
A motion token is a named design decision about time or movement, stored as a CSS custom property (and usually generated from a platform-neutral source such as JSON). There are two tiers.
Primitive tokens describe the raw scale: --duration-100 through --duration-500, --ease-standard, --ease-decelerate, --ease-accelerate, --distance-sm. They say nothing about usage and rarely change.
Semantic tokens describe roles: --motion-enter-duration, --motion-exit-easing, --motion-emphasis-scale. They reference primitives, and they are what components consume. When a brand wants calmer motion, semantic tokens are re-pointed at slower primitives; components do not change.
The problem the system solves is consistency at scale. The problem it introduces is indirection: a developer reading var(--motion-enter-duration) needs to know what that resolves to and why. Good naming and a visible reference page solve that.
Execution model
Tokens are a build-time and style-time concept; the rendering pipeline never sees them as anything but values. Understanding where they resolve prevents a class of subtle bugs.
Custom properties resolve at computed-value time. A declaration transition-duration: var(--motion-enter-duration) is resolved when the element’s style is computed. Changing the token on :root — for example when a reduced-motion media query matches — restyles every element that uses it. That is a single style recalculation, not per-frame work, and it is what makes context overrides cheap.
Transitions read the token when they start. A transition that is already running keeps the duration it started with. If a theme or density switch changes duration tokens mid-transition, only transitions that start afterwards use the new value. Animations behave the same for their timing properties.
Tokens inside keyframes resolve per element. A @keyframes rule that uses var(--distance-sm) reads the animating element’s value, so distance tokens can differ by context without duplicating keyframes — the technique in parameterising keyframes with custom properties.
Unregistered tokens cost nothing per frame. Durations and easings are static configuration; they should stay unregistered. Registering a token with @property only matters when the token itself is animated, which motion tokens almost never are.
Invalid tokens fail silently. A misspelt token without a fallback makes the declaration invalid at computed-value time, and the property falls back to its initial value — 0s for durations. A missing enter-duration token does not throw; it turns every entrance into an instant change. Fallbacks in shared mixins, and linting for unknown token names, catch this.
Property / API reference table
| Token or API | Accepted values | Compositing tier | Notes |
|---|---|---|---|
--duration-* primitives |
<time>: 100ms to 500ms in steps |
none (configuration) | Keep to 5–7 steps |
--ease-* primitives |
cubic-bezier(), linear() |
none | Standard, decelerate, accelerate, plus one expressive curve |
--distance-* primitives |
<length>: 4px to 32px |
none | Travel for slides and lifts |
--motion-enter-* / --motion-exit-* |
references to primitives | none | Consumed by components |
--motion-stagger-step |
<time> |
none | Delay between items in a group |
@media (prefers-reduced-motion: reduce) |
overrides for semantic tokens | style | One block re-points roles |
@property |
registration | style per frame if animated | Not needed for static tokens |
var(--token, fallback) |
fallback value | none | Guards against missing tokens |
transition / animation longhands |
token references | per animated property | Tokens decide timing; properties decide tier |
Annotated code examples
1 — Primitive scales and semantic roles
/* Primitives: the palette. Rarely edited. */
:root {
--duration-100: 100ms;
--duration-200: 160ms;
--duration-300: 240ms;
--duration-400: 320ms;
--duration-500: 440ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-decelerate: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-expressive: cubic-bezier(0.34, 1.4, 0.64, 1);
--distance-sm: 4px;
--distance-md: 12px;
--distance-lg: 24px;
}
/* Semantic roles: what components use. */
:root {
--motion-enter-duration: var(--duration-300);
--motion-enter-easing: var(--ease-decelerate);
--motion-exit-duration: var(--duration-200);
--motion-exit-easing: var(--ease-accelerate);
--motion-move-duration: var(--duration-300);
--motion-move-easing: var(--ease-standard);
--motion-enter-distance: var(--distance-md);
--motion-stagger-step: 40ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-enter-distance: 0px;
--motion-enter-duration: var(--duration-100);
--motion-exit-duration: var(--duration-100);
--motion-move-duration: 0ms;
--motion-stagger-step: 0ms;
}
}
Rendering Impact: none. Token declarations are configuration; they cost a style recalculation only when a context override such as the media query changes them.
The reduced-motion block is the most valuable twenty lines in the system. Every component that uses roles inherits the fallback — no travel, short fades, no staggered waiting — without writing its own media query. The detailed treatment is in reduced motion in design tokens.
2 — A component consuming roles
.toast {
opacity: 0;
translate: 0 var(--motion-enter-distance);
transition:
opacity var(--motion-exit-duration) var(--motion-exit-easing),
translate var(--motion-exit-duration) var(--motion-exit-easing);
}
.toast[data-open] {
opacity: 1;
translate: 0 0;
transition:
opacity var(--motion-enter-duration) var(--motion-enter-easing),
translate var(--motion-enter-duration) var(--motion-enter-easing);
}
@media (prefers-reduced-motion: reduce) {
/* Nothing needed here: the role tokens already changed. */
.toast { transition-property: opacity, translate; }
}
Rendering Impact: composite. The toast animates
opacityandtranslate; tokens decide the timing and distance, not the rendering tier.
Using the exit role on the closed state and the enter role on the open state follows the rule that the state being entered owns the transition, and the enter-decelerate, exit-accelerate pairing from choosing easing for enter and exit motion.
3 — Density and platform contexts
/* Dense data views: shorter, subtler motion. */
[data-density="compact"] {
--motion-enter-duration: var(--duration-200);
--motion-enter-distance: var(--distance-sm);
}
/* Large surfaces: longer, because more of the screen moves. */
.sheet, .fullscreen-dialog {
--motion-enter-duration: var(--duration-500);
--motion-exit-duration: var(--duration-300);
}
@media (prefers-reduced-motion: reduce) {
.sheet, .fullscreen-dialog {
--motion-enter-duration: var(--duration-100);
--motion-exit-duration: var(--duration-100);
}
}
Rendering Impact: none added. Scoped overrides are inherited custom properties resolved once per element’s style.
Scoped overrides need their own reduced-motion block: a value set on .sheet is closer in the tree than the :root value set inside the media query, so the element inherits the scoped value and the reduced-motion override never reaches it. That trap is the main argument for lint rules that flag token overrides outside the token files.
DevTools workflow
- Resolve a token. Select an animated element and open the Computed tab. Custom properties are listed with their resolved values; clicking through from
transition-durationshows which token supplied it. - Find literal values. In the Sources panel or a code search, look for
msandcubic-bezier(in component styles. Every hit outside the token files is a candidate for replacement. - Emulate reduced motion. Rendering → Emulate CSS media feature prefers-reduced-motion. Re-inspect the same element: role tokens should show their reduced values, and the Animations drawer should record fades without travel.
- Audit running timing. In the Animations drawer, select animation groups and read their durations. Groups whose durations are not on the scale indicate literals or broken token references.
- Catch missing tokens. A transition with a computed duration of
0son an element that should animate usually means a misspelt token with no fallback.
Failure modes & fixes
- Every entrance is suddenly instant. Root cause: a token was renamed and components still reference the old name, so durations resolve to
0s. Fix: add fallbacks in shared mixins and lint for unknown token names. - Reduced motion works everywhere except sheets. Root cause: a component-scoped token override is not repeated inside the reduced-motion media query. Fix: keep overrides in token files that pair each override with its reduced variant, or re-point scoped roles at role tokens rather than primitives.
- The system has twenty-four duration tokens. Root cause: tokens were created for each one-off value found in the audit. Fix: collapse to a scale of five to seven steps and map existing values to the nearest step.
- Designers and engineers disagree about what “standard” means. Root cause: tokens exist in code but not in the design tool or specs. Fix: publish the scale with visual examples and use the same names in specs, as in writing motion specs for design handoff.
- Native apps feel different from the web. Root cause: tokens are hand-copied into each platform. Fix: generate platform outputs from one source, covered in sharing motion tokens across web and native.
Accessibility and reduced-motion notes
A token system is the most reliable place to implement reduced motion, because it is the one place every animation already depends on. Re-pointing semantic roles removes travel, collapses staggers and shortens durations across the product at once, and new components inherit the behaviour without their authors knowing about it.
Tokens alone are not the whole story. A token can shorten a parallax effect but cannot decide that parallax should not exist under reduced motion — that decision belongs to the component, following vestibular-safe motion patterns. Treat tokens as the default floor, and require components with large-area, looping or scroll-linked motion to document their reduced-motion behaviour explicitly.
Keep a minimum duration for fades under reduced motion rather than zero. A 100ms opacity change still signals that something appeared or disappeared, which helps users follow state changes, while removing the spatial movement that causes discomfort.
Rolling tokens out to an existing codebase
Introducing tokens into a product that already ships hundreds of animations works best as a migration, not a rewrite. Start with the audit described in auditing a codebase for motion consistency: count the distinct durations and easings, group them by what they are used for, and map each cluster to the nearest step on the new scale. Most teams find that three quarters of their values collapse into four or five tokens without anyone noticing a visual change.
Migrate shared components first — buttons, menus, dialogs, toasts — because they account for most of the motion users see and because fixing them fixes every screen that uses them. Then add a lint rule in warning mode, so new literals are flagged without blocking work, and switch it to an error once the remaining hits are in code nobody is actively changing. Throughout, keep the reduced-motion overrides in the token files from day one; it is the part of the migration that delivers user value before the visual consistency does.
Finally, publish the scale where designers work. A token that exists only in CSS will be re-invented in the next mockup under a different name.
Frequently asked questions
How many duration tokens should a design system have?
Five to seven steps is enough for almost every product. More steps invite arbitrary choices between near-identical values and make the system harder to learn.
Should components use primitive or semantic motion tokens?
Semantic tokens. They express intent such as enter or exit, so the system can change the underlying values or apply reduced motion without touching components.
Do CSS custom property tokens slow animations down?
No. Unregistered tokens are resolved when styles are computed, not per frame. The animated properties decide the rendering cost.
Where should reduced-motion handling live?
In the token layer first, by re-pointing semantic roles in a prefers-reduced-motion media query. Components with high-risk motion still need their own decisions.
Can motion tokens be shared with iOS and Android apps?
Yes, if they are stored in a platform-neutral format and generated for each platform. Easing curves translate directly; durations may need platform-specific adjustments.
Related
- Timing Functions & Easing Curves — the curves the easing tokens name
- Building a Duration and Easing Token Scale — designing the primitive scales
- Choreographing Multi-Element Motion — rules for groups of elements
- Enforcing Motion Tokens with Stylelint — keeping literals out of components
- Reduced Motion in Design Tokens — the accessibility layer in depth