Skip to main content

Learning Track

Filter content by your role to see the most relevant sections

Motion & Duration Foundations

Design motion as a tokenized system: duration scales, easing vocabularies, stagger choreography, interaction composites, and prefers-reduced-motion as a first-class contract rather than an afterthought.

Darian Rosebrook
Staff Design Technologist, Design Systems Architect
Expertise:Design Systems, Motion Design, Accessibility

Why This Matters

Motion is the only foundation users experience in time. A color either matches or it does not; an animation can be almost right and still feel wrong—too slow to feel responsive, too fast to be perceived, too bouncy to be trusted. That sensitivity is exactly why motion cannot be tuned per component by feel: the judgments that make motion feel deliberate are relative ones, and relative judgments need a shared scale.

Motion also carries a unique obligation the other foundations do not: for a meaningful minority of users, animation is not a preference but a health risk. Vestibular disorders turn large translations and parallax into nausea triggers. A motion system that treats reduced-motion as a runtime contract—checked the same way contrast is checked—is the difference between inclusive motion and motion that excludes.

This page walks the motion system as it exists in this repository: a duration scale and easing vocabulary in ui/designTokens/core/motion.tokens.json, interaction composites in the semantic layer, and a reduced-motion architecture that spans CSS media queries and a React context.

Core Concepts: The Parts of a Motion Language

A motion language decomposes into four independently tokenizable decisions. Separating them is what makes motion composable—when duration, easing, distance, and sequencing are each a scale, an animation is four references instead of a bespoke invention.

Durations: A Scale, Not a Number

The core layer defines sixteen duration tokens spanning 50ms to 1500ms. The names, not the values, are the interface:

// ui/designTokens/core/motion.tokens.json (values)
"instant":  "100ms"   // immediate feedback (presses)
"short1":    "50ms"   // minimal perceivable change
"short2":    "83ms"   // hover transitions (≈5 frames)
"short3":   "167ms"   // focus appearance, quick exits
"short":    "150ms"   // general quick transitions
"medium0":  "300ms"   // deliberate standard transitions
"medium1":  "250ms"   // standard enters and exits
"medium2":  "333ms"   // comfortable entrances (modals)
"medium3":  "500ms"   // emphasis, large surfaces
"long0":    "600ms"   // spacious transitions
"long1":    "667ms"   // dramatic entrances
"long2":    "833ms"   // page-level choreography
"long3":   "1000ms"   // hero moments only
"extraLong1": "1500ms" // ambient/loading loops

Two derivations shaped this scale, and they pull in different directions. First, every value is a whole number of frames at 60fps—3 frames (50ms) through 90 (1500ms)—which keeps animations off mid-frame boundaries. Second, perception: a duration change needs roughly a 15% difference to be noticed at all, so useful scale steps sit at least that far apart (the spacing scale's 33–100% jumps clear the floor comfortably; so do the composites below, at 20–67%). Where the two derivations collide, frame alignment loses—nobody perceives a frame boundary.

Auditing the scale against that floor finds four collisions: 150/167ms (+11%), 300/333ms (+11%), 600/667ms (+11%), and medium/medium1, which are both 250ms. Those are name-only distinctions—two tokens for one perceptual answer—and they are the classic seed of drift: two teams pick differently by name, no reviewer can arbitrate because nothing looks different. The names themselves have warts of the same kind: instant (100ms, 6 frames) is slower than short2 (83ms), and the base-versus-numbered relation is inconsistent—short outlasts short1, medium equals medium1, and long (400ms) is shorter than long1 (667ms). Scales earn trust when names predict values; here the repair is at the vocabulary layer— collapse or re-derive the sub-floor pairs—because the frame values underneath are fine.

The deeper lesson is that granularity and availability are separate decisions. The raw scale can afford over-provision—sixteen names, roughly twelve distinguishable answers—precisely because consumers are not offered it: the interaction composites are the availability dial, and the durations they expose (83, 100, 167, 250, 333ms) are all comfortably above the perceptual floor. The menu problem is solved at the tier that choosers actually see, not by pretending the raw scale is tight.

Easings: The Vocabulary of Feel

Duration says how long; easing says how it feels along the way. The core vocabulary keeps the set small and paired:

// ui/designTokens/core/motion.tokens.json (values)
"standard":     "cubic-bezier(0.4, 0, 0.2, 1)"     // default workhorse
"emphasizedIn": "cubic-bezier(0.2, 0, 0, 1)"       // sharp arrival
"emphasizedOut":"cubic-bezier(0.4, 0, 0.2, 1)"     // = standard (see below)
"quick": {
  "enter":      "cubic-bezier(0, 0, 0.1, 1)"       // sharp start, smooth end
  "exit":       "cubic-bezier(1, 0, 1, 1)"         // smooth start, sharp end
}
"soft": {
  "enter":      "cubic-bezier(0, 0, 0.7, 1)"       // gentle acceleration
  "exit":       "cubic-bezier(0.3, 0, 1, 1)"       // gentle deceleration
}
"continuous":   "cubic-bezier(0.3, 0, 0.7, 1)"     // smooth throughout
"bounce":       "cubic-bezier(0.3, 0, 0.1, 1.25)"  // slight overshoot
"linear":       "cubic-bezier(0, 0, 1, 1)"         // constant speed

The enter/exit pairing is the load-bearing idea. Enter and exit are different physical events: things arriving decelerate into place; things leaving accelerate away. A single "ease" token forces one physics on both. Tokens like quick.enter versus quick.exit encode the asymmetry—the exit curves are the enter curves run backward in character, which is also why exits are usually shorter than enters in the composite tokens below.

The same honesty applies here as with durations: emphasizedOut is currently identical to standard. Whether that is convergence or an unfilled slot, the vocabulary's shape (a distinct name) is right even where the value has not yet diverged.

Distance and Sequencing: Translate and Stagger

How far a thing moves changes how the duration reads—the same 250ms feels snappy over 10px and sluggish over 100px—so distance is tokenized too: motion.translate.none/sm/md at 0px/10px/20px. Sequencing gets its own scale in motion.stagger, and its base unit is the frame:

  • stagger.xs — 17ms, one frame at 60fps: sequences read as simultaneous-but-ordered
  • stagger.sm — 30ms: list items; the composed --core-motion-stagger-list alias resolves here
  • stagger.md — 60ms: cards ( --core-motion-stagger-card)
  • stagger.lg — 100ms: sections ( --core-motion-stagger-section)

Frame-anchored staggers exist because perceptual simultaneity has a hard boundary: below roughly one frame, sequence is invisible. Composed aliases like stagger.list exist so call sites say what they are animating, and the choreography decision lives in one place.

Interaction Composites: Where the Layers Meet

The semantic layer composes the primitives into named interactions—motion.interaction.*—exactly the way color composes ramps into roles:

// ui/designTokens/semantic/motion.tokens.json (excerpt)
"interaction": {
  "press":  { "duration": "{motion.duration.instant}",
              "easing":   "{motion.easing.standard}",
              "scale":    0.98 },
  "hover":  { "duration": "{motion.duration.short2}",
              "easing":   "{motion.easing.quick.enter}" },
  "focus":  { "duration": "{motion.duration.short3}",
              "easing":   "{motion.easing.soft.enter}" },
  "enter":  { "duration": "{motion.duration.medium1}",
              "easing":   "{motion.easing.soft.enter}" },
  "exit":   { "duration": "{motion.duration.short3}",
              "easing":   "{motion.easing.quick.exit}" },
  "modal": {
    "enter": { "duration": "{motion.duration.medium2}",
               "easing":   "{motion.easing.soft.enter}" },
    "exit":  { "duration": "{motion.duration.medium1}",
               "easing":   "{motion.easing.soft.exit}" } },
  "tooltip": {
    "enter": { "duration": "{motion.duration.short3}",
               "easing":   "{motion.easing.quick.enter}",
               "delay":    "{motion.delay.medium}" },
    "exit":  { "duration": "{motion.duration.short1}",
               "easing":   "{motion.easing.quick.exit}" } }
}

Read hover as a sentence: 83ms, quick-enter easing. Read tooltip as a designed opinion: enter after a 100ms delay (so grazing the trigger does not spam tooltips), disappear in 50ms (nobody needs to watch a tooltip leave). These composites are where motion design judgment gets encoded—once— instead of re-argued in every component.

Reduced Motion Is a Contract, Not a Fallback

The system supports prefers-reduced-motion with two coordinated mechanisms, and both are load-bearing:

  • CSS: component styles wrap transitions and keyframe animations in @media (prefers-reduced-motion: reduce) blocks— dozens of components ship them, from Button and Dialog to Toast and Skeleton. The pattern is uniform: reduce to instant or opacity-only motion.
  • React: a ReducedMotionContext provider feeds a useReducedMotion() hook that combines window.matchMedia with a localStorage (reduce-motion) override, so scripted animations—page transitions, walk-throughs, scroll reveals—can respect the same signal the CSS does, plus a manual switch for users whose browsers do not expose the media query.

The reason both exist: media queries can only style, but much of today's motion is orchestrated in JavaScript. When CSS and script read different signals, users get the worst of both—a reduced CSS layer with full-motion scroll choreography on top. One signal, two consumers, zero divergence.

System Roles: Where Motion Decisions Land

Motion fails organizationally before it fails technically, usually because the wrong layer owns the decision. The working split:

Design Impact

Designers own choreography—the ordering and emphasis of movement—and the composites encode it. What should enter first, the scrim or the sheet? Does this list stagger as one gesture or as independent items? These are judgment calls about attention, and they belong in named composites like motion.interaction.modal where they can be reviewed as motion design, not buried in a component's easing choice.

Engineering Impact

Engineers own the mechanism: keyframe tokens (fadeIn, slideUp, slideDown, scaleIn, pulse) that name animations once and reference them everywhere, and the CSS var emission that keeps every consumer on the same values. The discipline that keeps it clean is the same as color's: components reference tokens, never literal milliseconds or bezier strings.

Accessibility Impact

Accessibility owns the reduction semantics: what motion remains when the user opts out. Good reduced-motion is not no motion—opacity fades and color shifts usually survive, while translation, scale, and parallax do not. Encoding that policy per-keyframe (which animations reduce to what) turns a health accommodation into a reviewed, testable part of the system instead of a per-component conscience.

Performance Impact

Motion and performance share a budget. The compositor-friendly properties (transform, opacity) are the cheap ones; animating layout properties reflows the page per frame. Tokenizing distance as translate quietly steers animation toward transforms—the token set and the performance model reinforce each other, and the effect.backdropBlur tokens (4/8/12px) keep even expensive filters on a scale.

Design & Code Interplay

In the design tool, the motion system appears as three named scales a prototyper can reach for without inventing numbers:

  • Duration as a named set — modes or styles matching short1…extraLong1, so a prototype's "250ms" and production's motion.duration.medium1 are the same decision.
  • Easing as a curated library — the nine curves, named, with the enter/exit pairing preserved. The prototype that reaches for quick.enter hands engineering a requirement, not a suggestion.
  • Stagger as choreography data — delays expressed as stagger.sm/md/lg per list/card/section, so the feel of a sequenced reveal survives the design-to-code handoff instead of degrading to "add a delay."

The composite table is the contract between the disciplines: a designer specifies an interaction by name ("modal enter"), and the prototype's timing is the implementation's timing, because both read the same token. When a prototype needs something the vocabulary cannot express, that gap is a design-system bug report—the vocabulary grows by decision, not by drift.

Applied Example: Choreographing a Toast Stack

Toasts are the perfect motion exercise because they exercise every part of the language at once: they enter and exit, they stack (sequencing), they must not block interaction (duration), and they are exactly the kind of ambient movement some users need gone. Ship one end to end.

Step 1: Name the interactions before writing CSS

A toast enters from a screen edge and exits by retreating—but unlike a modal it is not a focal element, so its timing should read as peripheral. From the composites: enter is medium1 (250ms) with soft.enter; exit is short3 (167ms) with quick.exit. Enter slower than exit: arriving information is worth perceiving, departing information is not.

Step 2: Tokenize the travel

The slide distance is motion.translate.md (20px). Resist larger distances: the toast should read as sliding in from just off-stage, not crossing the theater. Distance and duration are one perceptual decision—250ms over 20px is gentle; the same 250ms over 120px is a whip.

Step 3: Sequence the stack with stagger

/* New toast pushes the stack; the push is the choreography */
.toastStack > .toast {
  transition: transform var(--core-motion-duration-medium1)
              var(--core-motion-easing-soft-enter);
}

.toastStack > .toast:nth-child(n+2) {
  /* Each older toast yields slightly after the new one lands */
  transition-delay: var(--core-motion-stagger-xs); /* 17ms, 1 frame */
}

One frame of cascade makes the stack move as a body rather than as synchronized rows. This is what stagger tokens are for: the difference between "list" and "flock" is usually 17ms.

Step 4: Write the reduced-motion variant deliberately

@media (prefers-reduced-motion: reduce) {
  .toast {
    /* No travel: fade only, and be honest about dismissal */
    animation-name: fadeIn;
    animation-duration: var(--core-motion-duration-short1);
  }
  .toastStack > .toast:nth-child(n+2) {
    transition-delay: 0ms; /* reflow the stack instantly */
  }
}

Notice the policy choice encoded here: fades survive, translation does not, and the stack reflow becomes instant because spatial shuffling is exactly the motion vestibular users need avoided. The toast still communicates—the information arrives and departs—but nothing moves.

Step 5: Check the auto-dismiss against the duration scale

How long a toast stays is not animation, but it is time, and the scale keeps it coherent: a toast that enters in 250ms and auto-dismisses at medium3 (500ms) would vanish while still arriving. Dismissal timers want the long end of the scale or beyond it—seconds, not milliseconds—and that boundary is exactly where animation tokens end and product behavior tokens would begin.

Constraints & Trade-offs

Motion's tradeoffs are unusual because the failure modes are perceptual and accessibility-bound rather than visual. The ones that shape this system:

  • Scale size vs expressive range: sixteen durations and nine easings cover most product motion, but brand-moment animations will want things the vocabulary does not say. The escape hatch is a new named token with review— never an inline bezier in a component, which is how vocabularies silently become folklore.
  • Exit speed vs perceived stability: fast exits feel responsive but can make the interface feel like it is hiding things. The composites take the position that exits outrank enters in speed but never match instant; where that is wrong, change the composite—the reasoning lives there.
  • Stagger delight vs scan speed: sequenced reveals are charming exactly once and costly every time after. Long staggers on lists users re-visit daily tax everyone to delight someone once. The composed aliases keep the choreography adjustable in one place when the charm wears off.
  • One reduced-motion policy vs per-animation nuance: a blanket "no motion" rule throws away fades that help comprehension; a fully per-animation policy multiplies review surface. This system encodes policy per keyframe family (fades survive, travel does not)—coarse enough to audit, fine enough to respect.
  • CSS-only vs orchestrated motion: pure CSS motion is cheap and automatically respects media queries, but sequenced choreography wants script. Splitting the signal source (media query + context with override) costs a small sync surface and buys consistency across both worlds.
  • Frame-anchored staggers vs high-refresh displays: 17ms is one frame at 60fps and a third of one at 120Hz—on high-refresh hardware the same token reads differently. The values optimize for the median display; the honest answer for precision choreography is refresh-aware resolution, which the token structure permits but the current build does not do.

Common Pitfalls & Failure Modes

The failure modes that recur in motion systems, with the repairs that fit this architecture:

1. Literal durations in components

// Bad: Twenty components, twenty opinions about "quick"
.tooltip { transition: all 180ms ease-in-out; }

// Good: One opinion, at the tier that owns it: the tooltip composite
//    (short3 + quick.enter + delay.medium — see the semantic layer)
.tooltip {
  transition-duration: var(--core-motion-duration-short3, 167ms);
  transition-timing-function:
    var(--core-motion-easing-quick-enter, cubic-bezier(0, 0, 0.1, 1));
  transition-delay: var(--core-motion-delay-medium, 100ms);
}
// Hand-picking from the raw scale re-opens the decision the composites
// closed — and invites the sub-perceptual pairs nobody can arbitrate.

The damage from literals is comparative: 180ms next to 167ms next to 200ms reads as inconsistency no one can name, because the difference is invisible in review and obvious in product.

2. One easing for enter and exit

// Bad: Symmetric physics for asymmetric events
.menu { transition: transform 250ms var(--core-motion-easing-soft-enter); }
.menu[data-state='closed'] { /* same curve runs backward */ }

// Good: Exit is its own decision
.menu[data-state='open']   { transition-timing-function:
  var(--core-motion-easing-soft-enter); }
.menu[data-state='closed'] { transition-timing-function:
  var(--core-motion-easing-soft-exit); }

Reusing an enter curve for exit is not free symmetry; it is a physics claim—decelerating departures—that reads as sluggish dismissal.

3. Reduced motion bolted on at the end

// Bad: Motion shipped, reduction "added later" — later never comes
.hero { animation: parallax 30s linear infinite; }

// Good: The reduction ships in the same rule set
@media (prefers-reduced-motion: reduce) {
  .hero { animation: none; }
}

In this repository the pattern is established across dozens of components; the review norm that keeps it true is that a transition without its reduce block is incomplete, the way a color without its contrast check is incomplete.

4. Animating layout properties

// Bad: Reflows every frame
.accordion { transition: height 250ms; }

// Good: Transform, always transform
.accordionPanel {
  transform: scaleY(0);
  transform-origin: top;
  transition: transform var(--core-motion-duration-medium1)
              var(--core-motion-easing-soft-enter);
}

The token set quietly enforces the performant path—translate tokens are transforms—but height/width/top/left animations sneak back in wherever motion is not tokenized.

5. Stagger as a loop index

// Bad: Delay grows with list length; item 30 waits 900ms
.items:nth-child(n) { transition-delay: calc(n * 30ms); }

// Good: Cap the sequence — choreography, not pagination
.items:nth-child(1) { transition-delay: 0ms; }
.items:nth-child(2) { transition-delay: var(--core-motion-stagger-sm); }
.items:nth-child(3) { transition-delay: var(--core-motion-stagger-md); }
/* beyond three, items arrive with their row, not after it */

Uncapped stagger turns scroll position into wait time. Sequence the first movement, then stop sequencing.

6. Scripted motion ignoring the shared signal

// Bad: CSS respects the OS setting; the scroll reveal does not
useEffect(() => { animateOnScroll(element, { distance: 60 }); }, []);

// Good: One signal, both worlds
const { prefersReducedMotion } = useReducedMotion();
useEffect(() => {
  animateOnScroll(element,
    prefersReducedMotion ? { distance: 0, fade: true }
                         : { distance: 60 });
}, [prefersReducedMotion]);

A motion system earns its reduced-motion contract the moment orchestration code consults the same source of truth as the stylesheet. Divergence here is not a style bug; it is an accessibility defect.

Motion System Health Metrics

Motion health is checkable, and the checks are cheaper than the perceptual damage they prevent:

Signal 1: Token coverage

  • Healthy: component transitions and animations reference duration/easing custom properties (or composites) exclusively; a grep for literal ms and cubic-bezier in component CSS returns only token fallbacks.
  • Warning: literals cluster in one area—usually a legacy or imported feature—flagging where migration stalled.
  • Critical: new components ship with literal timing; the vocabulary has stopped being the default path.

Signal 2: Reduction coverage

  • Healthy: every rule set that animates also handles prefers-reduced-motion: reduce; scripted animation consumes useReducedMotion(); the two mechanisms never disagree.
  • Warning: CSS coverage is complete but scripted motion (scroll reveals, walkthroughs, transitions between routes) reads only the media query or only the override.
  • Critical: any traveling animation without a reduce path—this is a health-risk defect, prioritized like a contrast failure.

Signal 3: Vocabulary drift

  • Healthy: composites answer real interactions;
  • Warning: a composite is being overridden at call sites ("modal enter, but faster here"), meaning the composite no longer represents the design judgment.
  • Critical: token names whose values contradict their semantics (an instant slower than a short)—users of the vocabulary are being quietly lied to, and trust in names is the vocabulary's entire value.

Migration Strategy: From Ad-Hoc Animation to Tokens

Existing motion is usually scattered literals with inconsistent feel. The migration mirrors the color hex-to-token campaign:

  1. Inventory transitions and animations. Grep component styles for transition, animation, literal ms/s durations, and cubic-bezier literals. Group by approximate duration—most systems find three or four clusters hiding under a dozen different numbers.
  2. Snap clusters to the scale. Map each cluster to its nearest scale step (180ms→short3 167ms; 300ms→medium0). The visual diff of snapping is near-zero; the consistency dividend is immediate.
  3. Replace easing literals with vocabulary. ease-in-out maps to standard; the bespoke curves either match an existing token closely enough to snap, or they earn a new named token with review.
  4. Add reduce blocks where missing. Every file touched in steps 2–3 gets its prefers-reduced-motion handling in the same change—migration that drops accessibility debt is not migration.
  5. Turn on the checks per directory. As each area migrates, lint it against literal timing values so regression is blocked where cleanup happened.

A mechanical sketch of the snap pass:

// Literal → token fallback replacement, same shape as color
const durationMap = {
  '150ms': '--core-motion-duration-short',
  '180ms': '--core-motion-duration-short3',  // snapped 167ms
  '250ms': '--core-motion-duration-medium1',
  '300ms': '--core-motion-duration-medium0',
};

for (const file of componentCssFiles) {
  let css = read(file);
  for (const [literal, token] of Object.entries(durationMap)) {
    css = css.replaceAll(literal, `var(${token}, ${literal})`);
  }
  write(file, css); // literals survive as fallbacks; behavior holds
}

The snap pass deliberately changes feel by milliseconds at most; its purpose is to make the next motion decision cheap and consistent, and every subsequent tuning change lands in one token instead of forty files.

Verification Checklist

Additional Resources

Where to go next, inside and outside this system:

  • Design Tokens — the architecture these motion scales live in: formats, resolution, and validation (/blueprints/foundations/tokens).
  • Color Foundations — the sibling page showing the same three-layer pattern applied to ramps, roles, and component contracts (/blueprints/foundations/color).
  • CSS cascading and cascade layers — how the generated token stylesheet structures precedence (https://developer.mozilla.org/en-US/docs/Web/CSS/@layer).
  • Prefers-reduced-motion — the media query at the center of the reduction contract (https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).
  • The sources themselves — ui/designTokens/core/motion.tokens.json, ui/designTokens/semantic/motion.tokens.json, context/ReducedMotionContext.tsx, and the generated app/designTokens.scss.

Related Concepts

Reflection Questions