GitHub

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.

TokenValueTypical 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.

--easing-standard ease Starts and ends on screen — hover, colour, size
--easing-enter ease-out Arriving. Fast, then settles
--easing-exit ease-in Leaving. Slow, then accelerates away
--easing-emphasized ease-in-out Travels a distance and stays — drawers, sheets
--easing-linear linear Continuous loops ONLY — spinners

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.

Hover me
--transition-fast 150ms ease Hover, focus, small state flips
Hover me
--transition-base 200ms ease Default for most interactions
Hover me
--transition-slow 300ms ease Panels, overlays, larger moves

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);
}
TokenValueTypical 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.