When Claude Code loads it
Use when adding any animation, transition, hover or press effect, page transition, scroll effect, parallax, marquee, hero entrance, loader, canvas or shader; when something feels janky, slow, cheap or "too much"; or when `void lint`/`void smooth` report motion findings. Principles after Emil Kowalski (animations.dev).
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, emilkowal.ski/ui); recipe portions adapted from emilkowalski/skills (MIT, see references/CREDITS.md).
The 12 rules
- 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.
- Name the purpose before writing it: feedback, spatial continuity, state change, explanation, or (rare) delight. "Looks cool" is not a purpose.
- 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).
- Enter with ease-out, exit ~25% faster. Never
ease-inon UI (lint/ease-in-enter). Built-inease-outis too weak; void'sease-outtoken iscubic-bezier(0.23, 1, 0.32, 1). - Nothing appears from
scale(0). Start atscale(0.95–0.97)+opacity: 0(lint/scale-zero-entry). - Origin-aware. Popovers, menus and tooltips grow from their trigger (
transform-originfrom the primitive). Dialogs are the exception: centered. - Transitions for state, keyframes for autonomous motion. Transitions retarget when interrupted; keyframes restart from zero. Toggles, toasts and tabs use transitions.
- Compositor-only. Animate
transform/translate/scale/rotateandopacity. Neverwidth,height,top,left,margin,box-shadow(lint/animate-layout-prop,smooth/animate-layout-property). Nevertransition: all(lint/transition-all). - 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.
- Reduced motion = a finished static frame, never hidden content. Movement goes; short opacity may stay (
lint/no-reduced-motion,smooth/reduced-motion-ignored). - 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.
- 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 rules3. 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 | + 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
translateonly (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
revealutility (CSS scroll-drivenview(), 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: stickytrack + CSSview-timeline, or oneuseScroll), 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 + 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.
"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), nevermotion.div(+46 KB) orframer-motion. Frommotion/reactimport onlyLazyMotion,MotionConfig,domAnimation/domMaxand hooks; elements come frommotion/react-m(this is whatspeedmeans by banningmotionimports on marketing routes). One animation runtime per app; no GSAP next to motion.- Prefer a full
transformstring overx/y/scaleshorthands 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 orelement.style, never React state. - Scope
layoutIdwithuseId().
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
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.bun run build, thenvoid 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.- 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 abovereferences/timing.md— durations, easing decision flow, distance, stagger, springsreferences/webgl-canvas.md— when a canvas is justified, 2D hook, token → uniform colors, fallbacksreferences/3d-scenes.md— scroll-linked 3D: measured library costs, lazy-loaded OGLSceneCanvas, render-on-demand, asset pipelinereferences/backgrounds.md— section backgrounds: decision table, cheap-first ladder, shared-context pattern, measured CSS + OGL implementationsreferences/fallbacks.md— what a browser without scroll-driven animations renders; the keyframes-own-the-from-state rule and the checks that enforce itreferences/CREDITS.md— attribution and MIT notice for adapted material