Skip to content
UI Craft / docs

Motion

Decision ladder, duration + easing token scales, choreography, motion budget, reduced-motion contract.

Updated 2026-04-18

The unified motion reference — decision ladder, token scales, interaction rules, choreography, motion budget, spring vs tween, reduced-motion contract, and anti-patterns. One file covers every motion decision in the product, from hover states to page transitions.

Used by /ui-craft:animate to keep every hover, modal, and page transition on the same scale. Load this whenever a UI change touches entrance/exit, scroll reveals, stagger, or anything that transitions.

Decision ladder

  1. Justify every animation — motion must communicate something (hierarchy, state, spatial relationship). Decorative motion is noise.

  2. Frequency determines budget — 100+ times/day gets zero animation. Occasional (modals, drawers) get standard. First-time experiences can delight.

    FrequencyAnimation
    100+ times/day (keyboard shortcuts, command palette)None. Ever.
    Tens of times/day (hover, list nav, typing)Remove or drastically reduce — under 150ms or none
    Occasional (modals, drawers, toasts)Standard animation, 200–300ms, clear purpose
    Rare / first-time (onboarding, celebrations)Can add delight — tell a story
  3. Speed communicates confidence — under 200ms feels instant. 300ms+ starts feeling sluggish.

  4. Respect the user’s systemprefers-reduced-motion is not optional. Provide meaningful fallbacks, not just animation: none.

  5. GPU-only properties — stick to transform and opacity. Animating width, height, top, left causes layout thrashing.

  6. List properties explicitlytransition: all animates things you didn’t intend.

Asymmetric enter/exit. Pressing can be slow when deliberate (hold-to-delete: 2s linear), but release is always snappy (200ms ease-out). Slow where the user decides; fast where the system responds.

Duration scale

Five tokens cover ~95% of UI. Pick from this scale; do not invent.

:root {
  --motion-fast:   120ms; /* color / opacity, tooltip, hover */
  --motion-base:   200ms; /* small UI — dropdown, toggle, tab, select */
  --motion-medium: 280ms; /* medium UI — modal open, drawer, popover */
  --motion-slow:   400ms; /* large UI — page transition, full sheet */
  --motion-slower: 600ms; /* decorative / onboarding — sparingly */
}
TokenRangeUse for
--motion-fast120msOpacity, color, hover, focus ring, tooltip show/hide
--motion-base200msDropdowns, toggles, tabs, selects, chips, accordion
--motion-medium280msModals, popovers, drawers (desktop), snackbars
--motion-slow400msPage transitions, drawers (mobile), full-screen sheets
--motion-slower600msHero animations, onboarding choreography, first-run only

Never transition: 153ms. Never 200ms on one button and 220ms on another for the same state. A bespoke duration is a finding.

Easing scale

Four tokens cover ~95% of UI easing. System defaults — framework libraries have their own defaults (see stack.md for Motion / GSAP mapping).

:root {
  --ease-out:         cubic-bezier(0.22, 1, 0.36, 1);   /* entrances, most UI */
  --ease-in-out:      cubic-bezier(0.65, 0, 0.35, 1);   /* same-layer transitions */
  --ease-emphasized:  cubic-bezier(0.2, 0, 0, 1);       /* hero, attention-grabbing */
  --ease-soft:        cubic-bezier(0.4, 0, 0.2, 1);     /* Material-like, general purpose */
}
TokenWhen to use
--ease-outDefault for almost everything. Entrances, hover, dropdown, modal open.
--ease-in-outElements already on screen that move or morph (layout changes, repositioning).
--ease-emphasizedOne element per viewport — hero reveal, primary CTA emphasis.
--ease-softGentler alternative for general UI; Material-style products.

Never:

  • ease-in for UI. Slow start delays visual feedback. A dropdown with ease-in at 300ms feels slower than ease-out at the same duration.
  • linear except loading indicators, marquees, scroll-linked progress, hold-to-delete indicators.
  • Bespoke easings named “smooth” with cubic-bezier(0.5, 0.5, 0.5, 0.5). That’s a straight line.
  • Bounce / elastic easing on functional UI. Reads dated and draws attention to the animation itself.
  • Different easings on entrance vs exit of the same element.

Interaction rules

  • Interaction feedback <= 100ms. If it takes longer, it stops feeling like a reaction.
  • Hover contract. Transform + color + shadow move together, never just one property.
  • Focus contract. A focus ring still needs a perceptible transition (fast, no easing drama) so keyboard users see the state change.
  • Disable interactions on exiting elements. Visually present, logically gone.
  • Never block interaction while a stagger plays. Decorative motion must not gate input.

General interaction rules (keyboard timing, touch targets, overscroll-behavior, touch-action, optimistic UI, destructive confirmations) live in accessibility.md — honor them on every animated control.

Choreography rules

Exit runs at ~75% of entrance duration. This is the one rule that matters most. If entrance is --motion-medium (280ms), exit is ~200ms with --ease-out (or a flatter tail like cubic-bezier(0.4, 0, 1, 1)).

  1. Hierarchy first. Parent/container animates before child/content. Context arrives, then detail. Modal backdrop fades in (--motion-fast), then the modal panel slides (--motion-medium), then form fields enter (staggered).
  2. Stagger is 30–80ms between siblings. Never uniform at zero (feels pasted-on), never more than 80ms per item (list feels slow beyond the 6th item). Marketing pages can push to 100–150ms for dramatic entrances.
  3. Co-located properties share timing. Multiple properties on one element (transform + opacity + color on hover) use ONE duration + easing, not three competing. transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out); — not three different durations.
  4. Shared elements use layout animations. When the “same” element appears in two places (list → detail), use layoutId (Motion), View Transitions API, or FLIP — not discrete enter/exit pairs that visually unmount and remount.
  5. Exit mirrors initial for symmetry. If entering from opacity: 0, y: 20, exit to the same.
  6. Unique keys (not index) for dynamic lists — enables smooth add/remove.
  7. List reordering needs layout animation mode, not sequential mode.
  8. Wait modes nearly double perceived duration — use shorter durations when sequencing enter/exit.

Stagger example:

.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }

For multi-stage storyboards with named timing + config objects + stage-driven sequencing, see examples/animation-storyboard.md.

Motion budget per surface

A ceiling on simultaneous entrance animations. This is the tool against AI’s “animate everything on mount” slop.

SurfaceBudget
Landing heroUp to 3 staggered entrances (headline / subhead / CTA). One scroll-linked element max.
Feature section1 reveal-on-scroll per card, stagger 40ms, triggers once.
DashboardMicro-interactions only. No entrance animations on metric cards, charts, tables.
Forms1 focus ring transition per field. No field-by-field staggered entrance.
ModalsBackdrop fade + panel transform. Nothing inside the modal animates on open.
Settings / adminZero entrance animations. High-frequency tool — motion wastes time.
Onboarding (first-run)Larger budget — the one moment it pays off.

If a viewport has more than its budget, cut. Every “let’s add one more” compounds.

Spring vs tween

Pick spring OR tween globally, not per-component. Mixing in the same app reads as inconsistent. Document the choice in the project’s design doc.

Springs feel natural because they simulate real physics — no fixed duration, they settle based on physical parameters.

Use springs when:

  • Drag interactions with momentum
  • Elements that should feel “alive” (Dynamic Island)
  • Gestures that can be interrupted mid-animation (springs maintain velocity; CSS animations restart from zero)
  • Mouse-tracking decorative interactions (useSpring to interpolate)

Use tween when:

  • The project has strict duration budgets (200–300ms UI rule)
  • Deterministic motion matches the brand
  • Motion is purely functional (no “alive” personality)

Spring configuration (Motion / Framer):

// Apple-style (recommended — easier to reason about)
{ type: "spring", duration: 0.5, bounce: 0.2 }

// Traditional physics (more control)
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }

Spring rules:

  • Keep bounce subtle (0.1–0.3) when used; avoid in most functional UI.
  • Balanced parameters: stiffness: 500, damping: 30 settles quickly; stiffness: 1000, damping: 5 is too bouncy.
  • Drag release: { type: "spring", velocity: info.velocity.x } preserves input energy.
  • Springs have no fixed duration — constrain via bounce <= 0.25 and visualDuration.

Spring presets:

Use caseConfig
Cards / containers (smooth settle)stiffness: 300, damping: 30
Pop-ins / badges (snappy)stiffness: 500, damping: 25
Slides / entrances (balanced)stiffness: 350, damping: 28
Drag releasestiffness: 500, damping: 30 + velocity

Reduced-motion contract

prefers-reduced-motion is not optional. The system must degrade to zero-motion gracefully — not break, not disappear, not skip states.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Two exceptions to “disable everything”:

  • Loading indicators keep animating. Reduced motion removes gratuitous motion, not signal. A frozen spinner reads as “broken.”
  • Essential feedback stays. A focus ring still needs a perceptible transition (fast, no easing drama) so keyboard users see the state change.

Test: macOS → System Preferences → Accessibility → Display → “Reduce motion.” Windows → Settings → Accessibility → Visual effects → “Animation effects” off. Every entrance and exit should still deliver the user to the right visual state, instantly.

Anti-patterns

Any one of these invalidates the rest of the work.

  • Bespoke durations (transition-[153ms], duration: 0.23). Pick a token.
  • transition: all — list specific properties.
  • Easings named “smooth” with symmetric cubic-bezier(0.5, 0.5, 0.5, 0.5). That’s linear.
  • Bounce / elastic easing on functional UI. Bounce is for celebration, not buttons.
  • ease-in on UI — slow start reads as sluggish.
  • Parallax on body content (kills scroll performance; hostile to reduced-motion).
  • Scroll-jacking (hijacking the wheel). Users have a mental model for scroll. Don’t break it.
  • Motion on every hover (death by a thousand animations). Hover affordance is enough; not every row needs to move.
  • Entrance animations on every page load. First visit is special; the 47th is not.
  • Different easings on entrance vs exit of the same element. Asymmetry feels broken.
  • Exaggerated scale on hover (1.08+ on CTAs). Keep <= 1.02 for functional UI; 1.02–1.05 for cards.
  • Animating width / height / top / left — layout thrash. Use transform / opacity.

Figma / token export

Shareable tokens for Figma variables, Style Dictionary, or tokens.studio:

{
  "motion": {
    "duration": {
      "fast":    { "value": 120, "type": "duration", "unit": "ms" },
      "base":    { "value": 200, "type": "duration", "unit": "ms" },
      "medium":  { "value": 280, "type": "duration", "unit": "ms" },
      "slow":    { "value": 400, "type": "duration", "unit": "ms" },
      "slower":  { "value": 600, "type": "duration", "unit": "ms" }
    },
    "ease": {
      "out":        { "value": [0.22, 1, 0.36, 1],   "type": "cubicBezier" },
      "inOut":      { "value": [0.65, 0, 0.35, 1],   "type": "cubicBezier" },
      "emphasized": { "value": [0.2, 0, 0, 1],       "type": "cubicBezier" },
      "soft":       { "value": [0.4, 0, 0.2, 1],     "type": "cubicBezier" }
    }
  }
}

Figma handles cubicBezier via plugins; Style Dictionary compiles to CSS / JS / iOS / Android targets.

Framework mapping:

FrameworkDurationEasing
Vanilla CSSvar(--motion-base)var(--ease-out)
Tailwind (extend)theme.transitionDuration: { base: '200ms' }theme.transitionTimingFunction: { out: 'cubic-bezier(0.22, 1, 0.36, 1)' }
Motion (framer-motion)transition={{ duration: 0.2 }}transition={{ ease: [0.22, 1, 0.36, 1] }}
GSAPgsap.to(el, { duration: 0.2 })ease: 'power2.out' (~ease-out)

Map the project’s animation library to the token names, not raw values — when the scale changes, one file updates.

Sources

  • Material Design — Motion guidelines (duration scale, easing curves).
  • Apple Human Interface Guidelines — Motion and Accessibility.
  • IBM Carbon — Motion tokens.
  • W3C — prefers-reduced-motion media query, WCAG 2.3.3.

Spotted something out of date? Open an issue on GitHub →