Skip to content

Skill · 03 of 09

motion alive, fast, cheap

Motion and interaction animation for websites and web apps that feels expensive but costs almost nothing.

skills/motion/SKILL.md172 lines7 referencesupdated 16 Sept 2026

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

  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)

text
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
+ 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 + 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