# Animation Standards Reference The precise values, curves, and rules behind the review. Cite these in findings instead of approximating. Distilled from Emil Kowalski's design engineering philosophy. ## Should it animate? (frequency table) | Frequency | Decision | | --- | --- | | 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. | | Tens of times/day (hover effects, list navigation) | Remove or drastically reduce | | Occasional (modals, drawers, toasts) | Standard animation | | Rare / first-time (onboarding, feedback, celebrations) | Can add delight | **Never animate keyboard-initiated actions** — they repeat hundreds of times daily; animation makes them feel slow and disconnected. (Raycast has no open/close animation — correct for something used hundreds of times a day.) Valid purposes for motion: spatial consistency, state indication, explanation, feedback, preventing jarring change. "It looks cool" on a frequently-seen element is not valid. ## Easing Decision order: - Entering or exiting → **`ease-out`** (starts fast, feels responsive) - Moving / morphing on screen → **`ease-in-out`** - Hover / color change → **`ease`** - Constant motion (marquee, progress) → **`linear`** - Default → **`ease-out`** **Never `ease-in` on UI.** It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms *feels* faster than `ease-in` at 200ms. Built-in CSS easings are too weak. Use strong custom curves: ```css --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* strong ease-in-out for on-screen movement */ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer curve (Ionic) */ ``` Find curves at [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) — don't hand-roll from scratch. ## Duration | Element | Duration | | --- | --- | | Button press feedback | 100–160ms | | Tooltips, small popovers | 125–200ms | | Dropdowns, selects | 150–250ms | | Modals, drawers | 200–500ms | | Marketing / explanatory | Can be longer | **Rule: UI animations stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. Faster spinners make load feel faster (same actual time). Instant tooltips after the first (skip delay + animation) make a toolbar feel faster. ## Physicality - **Never `scale(0)`.** Start from `scale(0.9–0.97)` + `opacity: 0`. Nothing in the real world appears from nothing. - **Origin-aware popovers.** Scale from the trigger, not center: ```css .popover { transform-origin: var(--transform-origin); } /* Base UI */ ``` **Modals are exempt** — they appear centered in the viewport, keep `transform-origin: center`. - **Button press feedback.** `transform: scale(0.97)` on `:active`, `transition: transform 160ms ease-out`. Subtle (0.95–0.98). Applies to any pressable element. ## Springs Feel natural because they simulate physics; no fixed duration — they settle on parameters. Use for: drag with momentum, "alive" elements (Dynamic Island), interruptible gestures, decorative mouse-tracking. ```js // Apple-style (easier to reason about) — recommended { type: "spring", duration: 0.5, bounce: 0.2 } // Traditional physics (more control) { type: "spring", mass: 1, stiffness: 100, damping: 10 } ``` Keep bounce subtle (0.1–0.3); avoid bounce in most UI — reserve for drag-to-dismiss and playful interactions. Springs maintain velocity when interrupted (keyframes restart from zero), so they're ideal for gestures users may reverse mid-motion. Mouse interactions: interpolate with `useSpring` rather than tying value directly to mouse position (direct = artificial, no momentum). Only do this when the motion is decorative. ## Interruptibility CSS **transitions** can be interrupted and retargeted mid-animation; **keyframes** restart from zero. For anything triggered rapidly (toasts being added, toggles), transitions are smoother. ```css /* Interruptible — good for dynamic UI */ .toast { transition: transform 400ms ease; } /* Not interruptible — avoid for dynamic UI */ @keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } } ``` Use `@starting-style` for entry without JS: ```css .toast { opacity: 1; transform: translateY(0); transition: opacity 400ms ease, transform 400ms ease; @starting-style { opacity: 0; transform: translateY(100%); } } ``` Legacy fallback: `useEffect(() => setMounted(true), [])` + `data-mounted` attribute. ## Asymmetric timing Slow where the user is deciding, fast where the system responds. ```css .overlay { transition: clip-path 200ms ease-out; } /* release: fast */ .button:active .overlay { transition: clip-path 2s linear; } /* press: slow, deliberate */ ``` ## Performance - **Only animate `transform` and `opacity`** — they skip layout/paint and run on the GPU. `padding`/`margin`/`height`/`width`/`top`/`left` trigger all three rendering steps. - **Don't drive child transforms via a CSS variable on the parent** — it recalcs styles for all children. Set `transform` directly on the element. ```js element.style.setProperty('--swipe-amount', `${d}px`); // bad: recalc on all children element.style.transform = `translateY(${d}px)`; // good: only this element ``` - **Framer Motion shorthands are NOT hardware-accelerated.** `x`/`y`/`scale` run on the main thread via rAF and drop frames under load. Use the full transform string: ```jsx // drops frames under load // hardware accelerated ``` - **CSS animations beat JS under load** — they run off the main thread; rAF-based animations stutter while the browser loads/scripts/paints. Use CSS for predetermined motion, JS for dynamic/interruptible. - **WAAPI** gives JS control with CSS performance (hardware-accelerated, interruptible, no library): ```js element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], { duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' }); ``` ## Transforms & clip-path - **`translate` percentages** are relative to the element's own size — `translateY(100%)` moves by the element's height regardless of dimensions (how Sonner/Vaul position toasts/drawers). Prefer over hardcoded px. - **`scale()` scales children too** (font, icons, content) — a feature for press feedback. - **3D**: `rotateX/Y` + `transform-style: preserve-3d` for depth/orbit/flip without JS. - **`clip-path: inset(t r b l)`** is a powerful animation tool: each value eats in from that side. Uses: reveal-on-scroll (`inset(0 0 100% 0)` → `inset(0 0 0 0)`), hold-to-delete overlay, seamless tab color transitions (duplicate + clip the active copy), comparison sliders. ## Gestures & drag - **Momentum dismissal**: don't require crossing a distance threshold — compute velocity (`Math.abs(distance)/elapsedMs`); dismiss if `> ~0.11`. A flick should be enough. - **Damping at boundaries**: dragging past a natural edge moves less the further you go (real things slow before stopping). - **Pointer capture** once dragging starts, so it continues when the pointer leaves bounds. - **Multi-touch protection**: ignore extra touch points after the drag begins (`if (isDragging) return`) — prevents jumps. - **Friction over hard stops** — allow over-drag with rising resistance rather than an invisible wall. ## Masking imperfect crossfades When a crossfade shows two overlapping states despite tuning easing/duration, add subtle `filter: blur(2px)` during the transition to blend them into one perceived transformation. Keep blur < 20px (heavy blur is expensive, especially Safari). ## Stagger Stagger group entrances; 30–80ms between items. Longer delays feel slow. Stagger is decorative — never block interaction while it plays. ```css .item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; } .item:nth-child(2) { animation-delay: 50ms; } .item:nth-child(3) { animation-delay: 100ms; } @keyframes fadeIn { to { opacity: 1; transform: translateY(0); } } ``` ## Accessibility ```css @media (prefers-reduced-motion: reduce) { .element { animation: fade 0.2s ease; } /* keep opacity/color, drop transform-based motion */ } @media (hover: hover) and (pointer: fine) { .element:hover { transform: scale(1.05); } /* gate hover motion — touch fires false hovers on tap */ } ``` ```jsx const reduce = useReducedMotion(); const closedX = reduce ? 0 : '-100%'; ``` Reduced motion means fewer and gentler animations, not zero — keep transitions that aid comprehension, remove movement/position changes. ## Debugging (recommend in reviews when feel is uncertain) - **Slow motion**: bump duration 2–5× or use DevTools animation inspector. Check colors crossfade cleanly, easing doesn't stop abruptly, `transform-origin` is right, coordinated properties stay in sync. - **Frame-by-frame**: Chrome DevTools Animations panel reveals timing drift between coordinated properties. - **Real devices** for gestures (drawers, swipe) — connect a phone, hit the dev server by IP, use Safari remote devtools. - **Fresh eyes next day** — imperfections invisible during development surface later. ## Cohesion Match motion to the component's personality: playful can be bouncier; a professional dashboard should be crisp and fast. Sonner feels right partly because easing, duration, design, and even the name are in harmony — slightly slower, `ease` rather than `ease-out`, to feel elegant. Opacity + height in entering/exiting lists is trial and error; there's no formula — adjust until it feels right.