The precise values, curves, and rules behind the review. Cite these in findings instead of approximating. Distilled from Emil Kowalski's design engineering philosophy.
| 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.
Decision order:
ease-out (starts fast, feels responsive)ease-in-outeaselinearease-outNever 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:
--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 or easings.co — don't hand-roll from scratch.
| 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.
scale(0). Start from scale(0.9–0.97) + opacity: 0. Nothing in the real world appears from nothing..popover { transform-origin: var(--transform-origin); } /* Base UI */
Modals are exempt — they appear centered in the viewport, keep transform-origin: center.transform: scale(0.97) on :active, transition: transform 160ms ease-out. Subtle (0.95–0.98). Applies to any pressable element.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.
// 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.
CSS transitions can be interrupted and retargeted mid-animation; keyframes restart from zero. For anything triggered rapidly (toasts being added, toggles), transitions are smoother.
/* 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:
.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.
Slow where the user is deciding, fast where the system responds.
.overlay { transition: clip-path 200ms ease-out; } /* release: fast */
.button:active .overlay { transition: clip-path 2s linear; } /* press: slow, deliberate */
transform and opacity — they skip layout/paint and run on the GPU. padding/margin/height/width/top/left trigger all three rendering steps.transform directly on the element.
element.style.setProperty('--swipe-amount', `${d}px`); // bad: recalc on all children
element.style.transform = `translateY(${d}px)`; // good: only this element
x/y/scale run on the main thread via rAF and drop frames under load. Use the full transform string:
<motion.div animate={{ x: 100 }} /> // drops frames under load
<motion.div animate={{ transform: "translateX(100px)" }} /> // hardware accelerated
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)' });
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.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.Math.abs(distance)/elapsedMs); dismiss if > ~0.11. A flick should be enough.if (isDragging) return) — prevents jumps.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 group entrances; 30–80ms between items. Longer delays feel slow. Stagger is decorative — never block interaction while it plays.
.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); } }
@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 */
}
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.
transform-origin is right, coordinated properties stay in sync.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.