The default is no animation. An element earns motion only by answering a question the user is already asking — where did this come from, did my press register, what just changed — and if it answers none of them, ship it static. When motion is earned, the default shape is a CSS transition on transform and opacity, ease-out, under 300ms, with a prefers-reduced-motion path written in the same commit rather than a follow-up. This skill governs one element on its own clock: the moment a second element has to coordinate with the first — stagger, a shared element, a route change — that is transitions, and the moment the clock is a finger rather than a timer, that is gestures.
Read the project's motion system before writing a line. Grep for --ease, cubic-bezier, @theme, transition-timing-function, motion/framer-motion in package.json, useSpring, animate(, or a tokens/theme file. Whatever declares timing already wins: write your change in that mechanism, reuse its named curves and durations, and never introduce a second animation layer to fix one element. If the project ships a curve you disagree with, say so as a finding — do not quietly ship a competing one.
Quick Reference
| When | Open |
|---|---|
| Before writing a modal, drawer, popover, dropdown, tooltip, toast, or accordion from scratch — each has an exact recipe you should not re-derive | patterns.md |
| When the default curve set is not enough, a crossfade looks wrong, you need JS control over a CSS-quality animation, or you must pick the project's house curve family | techniques.md |
Framework 1 — should this animate at all?
Gate on frequency first. This is not a judgment call.
| The user triggers it | Decision |
|---|---|
| 100+ times a day (command palette, shortcuts) | No animation. Ever. |
| Tens of times a day (hover, list navigation) | Remove or drastically reduce |
| Occasionally (modal, drawer, toast) | Standard animation |
| Rarely / first run (onboarding, celebration) | Delight is affordable here |
Emil Kowalski's gate; Raycast ships no open/close animation on its command menu for exactly this reason. Never animate a keyboard-initiated action — arrow-key list navigation, shortcuts, focus jumps. Keyboard users move faster than any curve, and motion there reads as lag.
Framework 2 — which curve, which duration?
Entering or exiting → ease-out. Already on screen, moving or resizing → ease-in-out. Hover, color, shadow → ease. Constant-rate (spinner, progress-as-time) → linear.
Built-in keywords are too weak to read as intentional. Ship three tokens (Emil Kowalski; the drawer curve is Ionic Framework's):
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
| Element | Duration |
|---|---|
| Button press feedback | 100–160ms |
| Tooltips, small popovers | 125–200ms |
| Dropdowns, selects | 150–250ms |
| Modals, drawers | 200–500ms |
| Marketing / explanatory | May run longer |
Core Principles
-
Never enter from
scale(0). Nothing in the real world appears from nothing, so the eye reads it as a glitch rather than an arrival. Start atscale(0.95)withopacity: 0—0.97for anything under roughly a tooltip's size. Exception: a contextual icon cross-swap goes deeper,scale(0.3)→1, because on a 16px glyph a timid scale reads as a flicker instead of a swap. -
Never use
ease-inon a UI transition. It back-loads the movement across the exact frames the user is watching most closely;ease-outat200msfeels faster thanease-inat the same200ms. Exception: the exit half of an enter/exit pair, where a slow start is the quietness you want. -
Stay under
300msin product UI. "A 180ms dropdown feels more responsive than a 400ms one." The200–500msmodal/drawer band above is not a licence: the300–500mshalf of it belongs to full-viewport travel — a mobile sheet crossing the whole screen — and everything else lives under300ms. Exception: marketing and explanatory motion, which obeysmarketing-pages, not this ceiling. -
Scale popovers, dropdowns, and tooltips from their trigger. A menu that grows from the centre of the screen severs the link between the control pressed and the surface that appeared. Use
transform-origin: var(--transform-origin)(Base UI; Radix exposes--radix-popper-transform-origin). Exception: modals, which are anchored to the viewport rather than a trigger —transform-origin: centeris correct there, and reporting it is a worse error than missing it. -
Press feedback is
scale(0.97)on:active, as a transition at160msease-out. A transition lets a release mid-press glide back instead of snapping; keep the value in0.95–0.98, since below0.95it turns cartoonish. Exception: dense or high-frequency controls skip it — bake an opt-out prop into the Button rather than removing the transition. -
Reversible state gets a
transition; one-shots get@keyframes. A transition retargets from wherever the element currently is, so closing a drawer mid-open reverses from that position; a keyframe restarts at frame one and looks broken. Exception: sequences that always run to completion — a loader, a first-run entrance — where a keyframe is the simpler tool. -
Exits run 20–30% faster than enters, and travel less. The user has already decided to leave. Enter
250ms/ exit180ms, nudgingtranslateY(-8px)rather than the element's own height, and never cutting todisplay: none, which severs spatial context. Exception: when the destination is the information — a swiped-away card, a closing drawer — the full off-screen travel is the point. -
Animate
transformandopacityonly. Everything else forces layout or paint on the whole subtree;transition: allis a finding on sight. Exception:clip-pathandfilter: blur()are legitimate and hardware-friendly, with blur capped under20pxbecause it is expensive, especially in Safari — see techniques.md. -
Do not animate resting state on first paint. A component already sitting where it belongs when the page appears has no entrance to play; in Motion, set
initial={false}on theAnimatePresencewrapper for toggles, tabs, and icon swaps. Exception: a genuine first-run entrance depends on itsinitialprop —initial={false}there deletes the whole thing. -
Reduced motion means gentler, not zero. Keep opacity and colour response so the interface still answers the user; drop transform-based movement. Exception: large-surface vestibular motion — full-viewport parallax, a moving background — is removed outright rather than softened.
Smell / Fix
| Smell | Fix |
|---|---|
transition: all 400ms ease-in |
Name the properties, 200ms, var(--ease-out) |
Enter from scale(0) or translateY(100vh) |
scale(0.95) + opacity: 0, short travel |
Animating height, width, margin, top |
transform + opacity; grid-template-rows or clip-path for expansion |
| Popover scales from centre | transform-origin: var(--transform-origin) |
| Modal and overlay on different durations | Paired elements share duration and curve exactly |
@keyframes on an open/close toggle |
A transition, so it can be interrupted |
| Motion on arrow-key navigation | Remove it — keyboard-initiated actions never animate |
| Exit longer or louder than the enter | Exit 20–30% faster, translateY(-8px) |
| Hover state flickers | Animate a child so the parent's hover area stays still |
| 1px jitter at the start or end | will-change: transform on that element only |
| No reduced-motion path | Add it in the same commit, not the next one |
| A new dependency for a fade | Use the project's existing stack |
Output
Ship the value, never the adjective. Every proposal names the property, the duration in milliseconds, and the curve as a token — transform 200ms var(--ease-out) — plus the reduced-motion path. "Tighten the timing" is not an answer.
Checklist
- Frequency gate passed, and the animation's purpose is nameable in one word
-
transform/opacityonly; notransition: all - Curve matches enter / move / hover / linear, and is a project token
- Duration under
300ms(or justified full-viewport travel), exit faster than enter - Enters from
scale(0.95)–0.97, neverscale(0) - Origin-aware if it is anchored to a trigger; centred if it is a modal
- Reversible state uses a
transition, not@keyframes -
:activefeedback present unless the control is high-frequency -
prefers-reduced-motionpath exists and keeps opacity response - Written in the project's existing motion system, not beside it