Motion
Motion is a composite token family. Tier 1 holds the ingredients separately — --duration-* and --easing-* — and tier 2 combines them into the token you actually write. There are two tier-2 sets because CSS has two motion properties: --transition-* for transition, --animation-* for animation.
Tier 1 — duration
How long. Named for the value they hold, because with timing the number is the meaning — --duration-150 tells you more than --duration-300 would. Never write one of these directly on a transition; reach for a tier-2 token and drop to tier 1 only when composing a new one.
| Token | Value | Typical use |
|---|---|---|
--duration-0 | 0ms | The reduced-motion destination |
--duration-75 | 75ms | Barely perceptible — colour flips |
--duration-100 | 100ms | |
--duration-150 | 150ms | The workhorse |
--duration-200 | 200ms | |
--duration-250 | 250ms | Large surfaces arriving |
--duration-300 | 300ms | Spatial moves |
--duration-500 | 500ms | |
--duration-750 | 750ms | Spinner revolution |
--duration-1000 | 1000ms | |
--duration-1500 | 1500ms | Indeterminate sweep |
Tier 1 — easing
How it gets there. Hover a row — every dot travels the same distance over the same 900ms, so the only difference you see is the curve.
Why ease and not a designed curve? --easing-standard is CSS's built-in ease, which is exactly what every transition in the system already used. Keeping it verbatim meant introducing this scale changed no rendered timing. Replacing it with a bespoke cubic-bezier() is a decision about how the system feels and gets made on its own terms, not smuggled in as part of a restructure.
Tier 2 — transitions
For state changes the browser interpolates. Each swatch uses its token for both the colour shift and the slide. Hover to trigger; the timing is the difference.
Tier 2 — animations
For keyframed sequences. These carry duration, easing and iteration but not the @keyframes name — that stays with the component, because the shape of the move is the component's business and its timing is the system's.
.esa-dropdown__panel {
animation: esa-dropdown-fade var(--animation-enter);
}
.esa-spinner {
animation: esa-spinner-rotate var(--animation-spin);
} | Token | Value | Typical use |
|---|---|---|
--animation-enter | 150ms ease-out | Dropdown, tooltip, popover, palette |
--animation-exit | 150ms ease-in | The counterpart — dismissals |
--animation-overlay-enter | 250ms ease-out | Sheets, side dialogs, snackbar |
--animation-overlay-exit | 200ms ease-in | |
--animation-spin | 750ms linear infinite | Spinners, button loading |
--animation-indeterminate | 1500ms ease-in-out infinite | Progress-bar sweep |
Usage
Bind the token on the transition property and name the properties you animate — never animate all. Default to --transition-fast unless the change is larger (base) or spatial (slow).
.button {
transition: background var(--transition-fast),
box-shadow var(--transition-fast);
}
.drawer {
transition: transform var(--transition-slow);
} Reach for tier 1 only when you are composing a timing the system does not have yet — and when you do, consider whether it should become a tier-2 token instead:
/* justified: a bespoke pairing, written once, in one component */
transition: transform var(--duration-500) var(--easing-emphasized); Reduced motion
Tokens ship a @media (prefers-reduced-motion: reduce) block that re-points every transition and enter/exit token at --duration-0. Anything bound to a tier-2 motion token honours the preference with no work from the component.
Loading indicators are deliberately exempt. --animation-spin and --animation-indeterminate keep running: reduce means remove non-essential motion, and a frozen spinner reads as a hung app. This is also why the override lives at tier 2 — tier 2 knows which motion is feedback and which is decoration, and the duration scale cannot.
The override only reaches tokenised call sites. Components that still hardcode 150ms ease ignore the preference, and a fair number do — moving them onto these tokens is what makes the block worth having.