1. Duration by use
| Interaction | Duration | Token | Measured / source |
|---|---|---|---|
| Keyboard-driven UI (⌘K, list highlight, key-switched tabs) | 0ms | --duration-instant |
Raycast has no palette animation; Emil Kowalski |
| Link color, hover color/bg | 100–150ms | --duration-fast |
Linear links 100ms, Vercel 150ms |
Button press (:active) |
100–160ms | --duration-fast |
Linear 160ms scale .97 |
| Tooltip | 125–150ms | --duration-base or literal 125ms |
Emil 125–200 |
| Dropdown, popover, select, tabs | 150–250ms | --duration-base |
Stripe nav 240ms, Anthropic dropdown 200ms |
| Accordion | 200–360ms | --duration-base–--duration-slow |
Stripe accordion 360ms |
| Dialog, toast | 200–350ms | --duration-slow |
Emil modal 200–300 |
| Drawer / sheet | 300–500ms | literal 450ms + ease-drawer |
Vaul 500ms |
| Page transition | 250–400ms | --duration-slow–--duration-slower |
perf rule PERF-56 ≤ 400ms |
| Marketing entrance / reveal | 400–700ms | --duration-slower |
Supabase 400, Stripe 500, Granola/Attio 600, Mercury 670 |
| Hero choreography total | ≤ 1000ms | — | Linear H1 1000–1500 (upper bound, don't exceed) |
| Ambient pulse / shimmer | 1.5–6s loop | literal | pulses 2–6s |
| Marquee | 30–180s loop | literal | Resend 180s, Supabase 30–35s |
| Spinner rotation | 600–800ms/turn | literal | faster spin feels faster |
Reconciling "UI < 300ms" with longer marketing entrances: they are different token groups. Components the user operates repeatedly stay ≤ 300ms (fast/base/slow). One-time, explanatory marketing motion may use --duration-slower (300–600ms depending on direction). lint/long-ui-duration flags interaction feedback over 500ms.
Exit = 70–80% of enter, and half the travel. Closed-state rules carry --duration-fast, open-state rules --duration-slow, so exits are faster automatically.
Distance: add ~50ms per extra 100px of travel beyond 100px. Mobile full-screen sheets 300–400ms; desktop equivalents 20–30% shorter.
Direction personality (already in the token values): precision quick and exact; editorial slow fades, no scale; swiss linear and near-instant (--ease-out: linear, fast = 0); warm springy panels; terminal steps() and 60–100ms; instrument minimal.
2. Easing decision flow
Entering or exiting the screen? → ease-out (never ease-in)
Moving/morphing while staying on screen? → ease-in-out (tab indicator, reorder, carousel slide)
Simple color / opacity hover? → ease (CSS keyword) or ease-out
Constant motion or progress? → ease-linear (marquee, hold-to-confirm fill, spinner)
Sheet / drawer? → ease-drawer
Marketing entrance or reveal? → ease-out-expo
Gesture release with velocity? → spring (motion) { type: "spring", duration: 0.4, bounce: 0 }
Success / playful moment (warm only)? → ease-spring-bouncy (or bounce 0.15–0.25)
Otherwise → ease-outDon't invent cubic-beziers. The measured top-site curves are already the tokens: (.23,1,.32,1) out-quint (Linear, Vercel, Raycast), (.19,1,.22,1) / (.16,1,.3,1) out-expo (8 sites), (.32,.72,0,1) drawer (7 sites), (.77,0,.175,1) in-out-quart (6 sites). Tailwind's default ease-out (0,0,.2,1) is too weak and is overridden by void.
3. Travel and scale
| Motion | Start state |
|---|---|
| Popover / menu | opacity 0, scale(.96) from trigger origin |
| Tooltip | opacity 0, scale(.97) |
| Dialog | opacity 0, scale(.96), centered origin |
| Toast | translateY(100%) from its edge |
| Drawer | translateY(100%) |
| Reveal | opacity 0, translateY(8–16px) (reveal uses 12px) or masked translateY(105%) |
| Hero H1 | translate 0.3em, no opacity |
| Press | scale(.97) (surfaces .99) |
| Hover lift | translateY(-2px) max |
Never scale(0), never travel > 40px for reveals (measured 8–24px), blur ≤ 4px on small elements only.
4. Stagger
| Case | Step | Cap |
|---|---|---|
| Hero lockup (eyebrow, H1, sub, CTAs) | 60ms | 4–5 items |
| Card/list group on first reveal | 40–60ms | 6 items / 300ms total; the rest share the last delay |
| Per word | 20–40ms | one sentence |
| Per character | avoid (15–30ms if unavoidable; one DOM node per glyph) | a single short word |
Stagger never blocks input, never replays on filter/sort/re-render, and collapses to 0 under reduced motion (base zeroes durations and delays).
5. Springs (motion library)
| Feel | Config |
|---|---|
| UI (sheets, layout) | { type: "spring", duration: 0.35–0.45, bounce: 0 } |
| Gesture release | { type: "spring", duration: 0.5, bounce: 0.1 } keeps velocity when interrupted |
| Playful (warm success) | { type: "spring", duration: 0.5, bounce: 0.2 } |
| Pointer-follow (decorative) | useSpring(value, { stiffness: 300, damping: 30 }) |
Near-critical damping. An overdamped spring (damping 60) took 5s to settle in the owner's app and was the top idle CPU cost. No springs on opacity or color. CSS springs: ease-spring (300/30, no overshoot, pair with ~300ms) and ease-spring-bouncy (400/24, ~9% overshoot, ~450ms).
6. Reduced motion
| Motion type | Under prefers-reduced-motion: reduce |
|---|---|
| Press, hover color | keep (≤ 100ms, not spatial) |
| Popover / dialog / toast | opacity only or instant (base zeroes base/slow) |
| Reveals, parallax, scrubbed scenes, marquees | removed, final frame shown |
| Page transitions | none |
| Canvas / WebGL | one static frame |
| Progress, spinners | keep (they convey state), slower or static bar |
CSS: base.css handles durations; recipes wrap spatial animation in @media (prefers-reduced-motion: no-preference). JS: or matchMedia("(prefers-reduced-motion: reduce)") read in an effect (server snapshot false).