---
title: "Timing: durations, easing, stagger, springs (motion)"
description: "Don'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) /…"
canonical: https://void-design.vercel.app/docs/motion/timing
lastModified: 2026-09-16
---

# Timing: durations, easing, stagger, springs

## 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-out
```

Don'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: `<MotionConfig reducedMotion="user">` or `matchMedia("(prefers-reduced-motion: reduce)")` read in an effect (server snapshot `false`).
