---
title: "motion skill: alive, fast, cheap"
description: "Motion and interaction animation for websites and web apps that feels expensive but costs almost nothing."
canonical: https://void-design.vercel.app/docs/motion
lastModified: 2026-09-16
---

# motion — alive, fast, cheap

Motion exists to explain cause and effect, keep spatial continuity, or confirm an action. Rarely, it is delight. Most great sites animate far less than generated ones: Emil Kowalski's homepage animates nothing; Vercel had nothing moving 3.5s after load.
Performance rules for animation live in `speed`. Library effects (marquee, spotlight, border beam, number ticker, aurora) and how to source them live in `components`. This skill decides **what moves, how, and how much**.

Principles below are paraphrased after Emil Kowalski ([animations.dev](https://animations.dev), [emilkowal.ski/ui](https://emilkowal.ski/ui)); recipe portions adapted from `emilkowalski/skills` (MIT, see `references/CREDITS.md`).

## The 12 rules

1. **Frequency decides.** 100+ times a day (⌘K, shortcuts, list arrow keys, tab switching by keyboard): **no animation**. Tens a day (hover, menus): near-instant. Occasional (dialog, toast, drawer): standard. Rare (first visit hero, success moment): delight allowed.
2. **Name the purpose** before writing it: feedback, spatial continuity, state change, explanation, or (rare) delight. "Looks cool" is not a purpose.
3. **UI motion ≤ 300ms.** Press 100–160ms, tooltip 125–200, dropdown 150–250, dialog/drawer 200–500. Marketing entrances may run 300–600ms (measured reveals: Supabase 400, Stripe 500, Granola 600, Mercury 670).
4. **Enter with ease-out, exit ~25% faster.** Never `ease-in` on UI (`lint/ease-in-enter`). Built-in `ease-out` is too weak; void's `ease-out` token is `cubic-bezier(0.23, 1, 0.32, 1)`.
5. **Nothing appears from `scale(0)`.** Start at `scale(0.95–0.97)` + `opacity: 0` (`lint/scale-zero-entry`).
6. **Origin-aware.** Popovers, menus and tooltips grow from their trigger (`transform-origin` from the primitive). Dialogs are the exception: centered.
7. **Transitions for state, keyframes for autonomous motion.** Transitions retarget when interrupted; keyframes restart from zero. Toggles, toasts and tabs use transitions.
8. **Compositor-only.** Animate `transform`/`translate`/`scale`/`rotate` and `opacity`. Never `width`, `height`, `top`, `left`, `margin`, `box-shadow` (`lint/animate-layout-prop`, `smooth/animate-layout-property`). Never `transition: all` (`lint/transition-all`).
9. **Stagger 30–60ms per item, ≤ 6 items, ≤ 300ms total.** Never block input while it plays. Only on first reveal, never on re-render or filter.
10. **Reduced motion = a finished static frame**, never hidden content. Movement goes; short opacity may stay (`lint/no-reduced-motion`, `smooth/reduced-motion-ignored`).
11. **Budget:** one signature effect + at most one secondary per page; T0–T1 by default; ≤ 1 WebGL context; 0 decorative effects on app routes. 1–3 scroll reveals per page, not every section.
12. **Never hide the LCP.** The hero H1 and hero image render visible at first paint. Animate secondary elements, or move the H1 without fading it.

## 1. Tokens

Durations are plain CSS variables (set by the direction; reduced motion zeroes `base`, `slow`, `slower`). Use them with the v4 syntax `duration-(--duration-fast)`, never `duration-[--duration-fast]`.

| Token | base.css | precision | editorial | warm | terminal | Use |
|---|---|---|---|---|---|---|
| `--duration-instant` | 0 | 0 | 0 | 0 | 0 | keyboard-driven, subsequent tooltips |
| `--duration-fast` | 100ms | 100 | 150 | 150 | 60 | press, color, hover, exits |
| `--duration-base` | 150ms | 150 | 250 | 220 | 100 | tooltip, dropdown, tabs |
| `--duration-slow` | 250ms | 200 | 400 | 320 | 150 | dialog, drawer, toast, accordion |
| `--duration-slower` | 500ms | 450 | 600 | 500 | 300 | marketing entrance, page transition |

| Easing utility | Curve | Use |
|---|---|---|
| `ease-out` | `cubic-bezier(0.23, 1, 0.32, 1)` (swiss: `linear`) | enter, exit, press, most UI |
| `ease-in-out` | `cubic-bezier(0.77, 0, 0.175, 1)` | something moving while staying on screen (tab indicator, reorder) |
| `ease-out-expo` | `cubic-bezier(0.19, 1, 0.22, 1)` | marketing entrances, reveals (Family, Raycast-style) |
| `ease-drawer` | `cubic-bezier(0.32, 0.72, 0, 1)` | sheets/drawers (iOS curve; Linear, Vercel, Vaul) |
| `ease-spring` / `ease-spring-bouncy` | `linear()` spring samples | warm panels / success moments only |
| `ease-linear` | linear | marquees, progress, hold-to-confirm fills |
| CSS `ease` | — | simple color/hover fades |

Full decision flow, distance scaling and spring configs: `references/timing.md`.

## 2. Should it animate? (answer in order)

```
Is it triggered by keyboard or used 100+ times/day? ── yes → no animation (0ms)
Does it show cause→effect, continuity or state change? ── no → is it a rare, first-visit moment? ── no → don't animate
Can CSS do it (transition, @starting-style, keyframes, view()) ? ── yes → CSS (T1)
Is it gesture-driven, layout/shared-element, or exit orchestration? ── yes → motion library (T2, LazyMotion)
Is it a generative visual (particles, shader)? ── only as the page's one signature → T3/T4 with webgl-canvas rules
```

## 3. Effect tiers and budgets

| Tier | What | JS | Where | Per-page budget |
|---|---|---|---|---|
| **T0** static CSS | gradients, masks, grain, hairlines, shadows | 0 KB | everywhere | one light, one grain region, masked grids |
| **T1** CSS animated | transitions, keyframes, `@starting-style`, scroll-driven `view()`, View Transitions, `@property` | 0 KB | marketing/docs freely; app routes for state feedback only | ≤ 3 infinite animations per viewport, ≤ 1 of them paint-bound |
| **T2** JS / motion | pointer-driven values, springs, layout/exit, one `useScroll` set piece | rAF 0.3–1 KB; `LazyMotion`+`m` +32 KB; full `motion` +46 KB | marketing: 1 signature + 1 secondary; app: functional only (drawer, toast, reorder) | ≤ 2 T2 islands, one runtime per app |
| **T3** canvas 2D | particle/line fields, animated heatmaps | 2–12 KB own code | marketing hero or product-essential visual | 1 viewport-sized canvas |
| **T4** WebGL | shaders, globes, 3D | raw WebGL2 3–6 KB, `ogl` 10–14 KB, `cobe` 5 KB, `three` 140 KB+ | marketing hero only, never in lists, never on authenticated routes unless it is the product | **1 context, hard** |

Default to T0–T1. A page with a T3/T4 signature has no other T2+ effect. Every infinite animation pauses offscreen (`speed` → PauseOffscreen) and has a reduced-motion frame.

## 4. Interaction patterns (summary; code in `references/recipes.md`)

| Pattern | Spec |
|---|---|
| Button / card press | `press` utility: `scale: 0.97` on `:active`, `--duration-fast`, `ease-out`. Large surfaces (> 400px) 0.99 or none |
| Hover | color/background/border step in `--duration-fast`; lift at most `-translate-y-0.5` on cards; **never** `hover:scale-105`. Gate pointer effects with `pointer-fine:`. Animate a child if the hovered box moves (avoids flicker) |
| Tooltip | open delay 400–700ms; subsequent tooltips instant (no delay, no animation); enter 125–150ms scale 0.97 + fade |
| Dropdown / popover | scale 0.96 → 1 + fade from trigger origin, `--duration-base` in, `--duration-fast` out |
| Dialog | centered scale 0.96 + fade `--duration-slow` in, `--duration-fast` out; backdrop fades with it |
| Drawer / sheet | `translateY(100%)` → 0, `ease-drawer`, 300–500ms; drag: set `transform` directly, dismiss on velocity > 0.11 px/ms |
| Toast | from its edge, transitions not keyframes, stack with `translateY` + `scale(1 − 0.05×i)`; use `sonner` |
| Accordion | `<details>` + `interpolate-size`/`::details-content` (Chromium) or `grid-rows-[0fr]→[1fr]`; `--duration-base` |
| Tabs | one indicator moved with `translate`/`scale`, `ease-in-out`, `--duration-base`; instant when switched by keyboard |
| Command palette | no open/close animation |
| Number change | `tabular-nums`; animate once on first view only (`components` → NumberTicker / NumberFlow) |
| Theme switch | no transition at all: freeze transitions for one frame (template `theme-toggle.tsx`) |

## 5. Load and scroll choreography (marketing)

- **Load:** animate hero text only, once, ≤ 600ms total: eyebrow/sub/CTA rise 8–12px with 40–60ms stagger; the H1 either static or `translate` only (no opacity from 0). Measured: Paco 600ms 7-step enter, Attio 500ms. Never a preloader (hellohello's ~8s intro is the anti-pattern).
- **Scroll:** 1–3 set pieces per page. Use the `reveal` utility (CSS scroll-driven `view()`, 12px rise + fade, runs off the main thread in Chromium and Safari 26; Firefox and reduced motion get the static, fully visible element). Not on body copy, not on every section, never on the hero.
- **Scrubbed or pinned scenes:** at most one per page (`position: sticky` track + CSS `view-timeline`, or one `useScroll`), transform-only, never pinning native scroll with JS.
- **Ambient loops** (marquee, pulse, shimmer): 2–6s pulses, 30–180s marquees, paused offscreen, pausable (WCAG 2.2.2 for > 5s movement), static under reduced motion.

## 6. Scroll-driven animations and View Transitions

**Keyframes own the from-state.** A browser without `animation-timeline` drops the entire
`@supports` block, keyframes included — so any hidden value (`opacity: 0`, a collapsed `scale`,
an off-screen `translate`) that lives *outside* that block is never undone, and the content is
gone permanently for that audience. Firefox has no scroll-driven animation support at all, which
is roughly 3% of the web and 100% of a Firefox user's experience. `smooth/scroll-timeline-no-fallback`
and `lint/scroll-timeline-no-fallback` catch it; the rule, the two-render diff behind the check and
the correct patterns are in `references/fallbacks.md`.

| Feature | Chrome/Edge | Safari | Firefox | Use |
|---|---|---|---|---|
| `animation-timeline: view()/scroll()` | 115 | 26 | ✗ (flag) | enhancement only, inside `@supports (animation-timeline: view())` |
| `@starting-style` + `transition-behavior: allow-discrete` | 117 | 17.5 | 129 | entry animations for popover/dialog/`display` |
| Same-document View Transitions | 111 | 18 | 144 | page/state transitions, enhancement |
| Cross-document `@view-transition` | 126 | 18.2 | ✗ | MPA/Astro only |
| `interpolate-size`, `::details-content` | 129 / 131 | ✗ | ✗ | accordions, enhancement |
| `linear()` easing | 113 | 17.2 | 112 | springs in CSS, safe |

View Transitions: page-level ≤ 400ms (`--duration-slower` or less), ≤ 10 named elements, disabled under reduced motion (base's reduced-motion rule + `speed` snippet). React 19.2 `<ViewTransition>` + `next/link` `transitionTypes` in Next 16: `references/recipes.md` §11.

## 7. CSS or the motion library?

Use CSS unless you need one of: gesture with velocity (drag, swipe, throw), layout / shared-element animation (`layout`, `layoutId`), exit animations of React-unmounted trees with orchestration, or springs that must retarget mid-gesture.

```tsx
"use client";
import { LazyMotion, MotionConfig } from "motion/react";
import * as m from "motion/react-m";
const features = () => import("@/lib/motion-features").then((mod) => mod.default); // export { domAnimation as default } from "motion/react";

export function Sheet({ children }: { children: React.ReactNode }) {
  return (
    <LazyMotion features={features} strict>
      <MotionConfig reducedMotion="user">
        <m.div initial={{ transform: "translateY(100%)" }} animate={{ transform: "translateY(0%)" }}
          transition={{ type: "spring", duration: 0.45, bounce: 0 }}>{children}</m.div>
      </MotionConfig>
    </LazyMotion>
  );
}
```

- `LazyMotion` + `m` (+32 KB measured on Next 16.3.5), never `motion.div` (+46 KB) or `framer-motion`. From `motion/react` import only `LazyMotion`, `MotionConfig`, `domAnimation`/`domMax` and hooks; elements come from `motion/react-m` (this is what `speed` means by banning `motion` imports on marketing routes). One animation runtime per app; no GSAP next to motion.
- Prefer a full `transform` string over `x`/`y`/`scale` shorthands for motion that runs while the page is busy.
- Springs: `{ type: "spring", duration: 0.35–0.5, bounce: 0 }` for UI; bounce 0.1–0.25 only for playful moments. Near-critical damping, no multi-second settles.
- Never `.on("change")` or timers in render (`lint/motionvalue-subscribe-in-render`, `lint/timer-in-render`); per-frame values via MotionValues or `element.style`, never React state.
- Scope `layoutId` with `useId()`.

## 8. Smooth scroll, scroll-jacking, parallax

**Default: none.** No Lenis, locomotive-scroll, GSAP ScrollSmoother or pinning. Why: smooth-scroll libraries move scrolling onto the main thread (any long task freezes scroll), add a permanent rAF loop (~10 KB + CPU), override OS scroll physics and accessibility settings, and break in iframes and nested scrollers. Only agency showreels use them (hellohello, Dia, Framer); zero measured SaaS sites do.

If the user explicitly insists (portfolio/agency showreel): one instance mounted in the root layout; `lerp` ≥ 0.08; disabled under `prefers-reduced-motion` and on touch; destroyed in cleanup; route passes TBT ≤ 100ms (`perf/tbt-high`); nested scroll areas marked `data-lenis-prevent`; document the exception in the design statement.

Parallax: ≤ 6% translate via CSS `view()` on one image per viewport; none under reduced motion.

## 9. Canvas and WebGL

Only as the page's single signature, and only if all of `references/webgl-canvas.md` holds: one context, never in `.map()`, DPR ≤ 1.5 (1 for backdrops), ≤ 30fps, cancel rAF offscreen and when hidden, static frame under reduced motion, context-loss handlers, `next/dynamic` with `ssr: false`, a CSS fallback matching the average color. The WebGL2 hook is in `speed/references/rendering-smoothness.md` §8; the 2D canvas hook and design rules are in `references/webgl-canvas.md`. Try the CSS downgrade first (`inspire/references/effect-downgrades.md`). For a 3D object that moves with the scroll, use OGL (~14 KB gzip) with procedural geometry, never three.js/r3f/Spline on marketing routes (131–520 KB+). Follow `references/3d-scenes.md`.

For section **backgrounds** specifically (a hero or section sitting on an alive surface rather than flat `--bg`) — decision table by evoked feeling and measured cost tier, the cheap CSS/SVG ladder to try first, the one-shared-context multi-section pattern, and paste-ready OGL implementations (fluid distortion, particle field, pixel/dither grid, displacement grid): `references/backgrounds.md`.

## 10. Verify

1. `void lint`: `lint/transition-all`, `lint/animate-layout-prop`, `lint/scale-zero-entry`, `lint/ease-in-enter`, `lint/long-ui-duration`, `lint/no-reduced-motion`, `lint/raf-without-cancel`, `lint/webgl-in-map`, `lint/will-change-static`, `lint/tw-v3-arbitrary-var`.
2. `bun run build`, then `void smooth --start "bun run start -p 3100" --port 3100`: `smooth/long-frames-during-scroll`, `smooth/raf-loop-idle`, `smooth/multiple-webgl-contexts`, `smooth/reduced-motion-ignored`.
3. Watch each animation at 0.25× speed (DevTools → Animations) and once with OS reduced motion on. Check it again the next day with fresh eyes.

## References

- `references/recipes.md` — paste-ready CSS/TSX for every pattern above
- `references/timing.md` — durations, easing decision flow, distance, stagger, springs
- `references/webgl-canvas.md` — when a canvas is justified, 2D hook, token → uniform colors, fallbacks
- `references/3d-scenes.md` — scroll-linked 3D: measured library costs, lazy-loaded OGL `SceneCanvas`, render-on-demand, asset pipeline
- `references/backgrounds.md` — section backgrounds: decision table, cheap-first ladder, shared-context pattern, measured CSS + OGL implementations
- `references/fallbacks.md` — what a browser without scroll-driven animations renders; the keyframes-own-the-from-state rule and the checks that enforce it
- `references/CREDITS.md` — attribution and MIT notice for adapted material
