Moving One Element
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 at scale(0.95) with opacity: 0 — 0.97 for 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-in on a UI transition. It back-loads the movement across the exact frames the user is watching most closely; ease-out at 200ms feels faster than ease-in at the same 200ms. Exception: the exit half of an enter/exit pair, where a slow start is the quietness you want.
Stay under 300ms in product UI. "A 180ms dropdown feels more responsive than a 400ms one." The 200–500ms modal/drawer band above is not a licence: the 300–500ms half of it belongs to full-viewport travel — a mobile sheet crossing the whole screen — and everything else lives under 300ms. Exception: marketing and explanatory motion, which obeys marketing-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: center is correct there, and reporting it is a worse error than missing it.
Press feedback is scale(0.97) on :active, as a transition at 160ms ease-out. A transition lets a release mid-press glide back instead of snapping; keep the value in 0.95–0.98, since below 0.95 it 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 / exit 180ms, nudging translateY(-8px) rather than the element's own height, and never cutting to display: 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 transform and opacity only. Everything else forces layout or paint on the whole subtree; transition: all is a finding on sight. Exception: clip-path and filter: blur() are legitimate and hardware-friendly, with blur capped under 20px because 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 the AnimatePresence wrapper for toggles, tabs, and icon swaps. Exception: a genuine first-run entrance depends on its initial prop — 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
1---2name: motion3description: Use when adding or tuning one element's motion: whether to animate at all, easing curve, duration, enter/exit shape, press feedback, interruptibility. Multi-element choreography is `transitions`.4---567# Moving One Element89The 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`.**1011**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.1213## Quick Reference1415| When | Open |16| --- | --- |17| 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](references/patterns.md) |18| 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](references/techniques.md) |1920## Framework 1 — should this animate at all?2122Gate on frequency first. This is not a judgment call.2324| The user triggers it | Decision |25| --- | --- |26| 100+ times a day (command palette, shortcuts) | **No animation. Ever.** |27| Tens of times a day (hover, list navigation) | Remove or drastically reduce |28| Occasionally (modal, drawer, toast) | Standard animation |29| Rarely / first run (onboarding, celebration) | Delight is affordable here |3031Emil 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.3233## Framework 2 — which curve, which duration?3435Entering or exiting → `ease-out`. Already on screen, moving or resizing → `ease-in-out`. Hover, color, shadow → `ease`. Constant-rate (spinner, progress-as-time) → `linear`.3637Built-in keywords are too weak to read as intentional. Ship three tokens (Emil Kowalski; the drawer curve is Ionic Framework's):3839```css40--ease-out: cubic-bezier(0.23, 1, 0.32, 1);41--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);42--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);43```4445| Element | Duration |46| --- | --- |47| Button press feedback | `100–160ms` |48| Tooltips, small popovers | `125–200ms` |49| Dropdowns, selects | `150–250ms` |50| Modals, drawers | `200–500ms` |51| Marketing / explanatory | May run longer |5253## Core Principles54551. **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 at `scale(0.95)` with `opacity: 0` — `0.97` for 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.56572. **Never use `ease-in` on a UI transition.** It back-loads the movement across the exact frames the user is watching most closely; `ease-out` at `200ms` *feels* faster than `ease-in` at the same `200ms`. *Exception:* the exit half of an enter/exit pair, where a slow start is the quietness you want.58593. **Stay under `300ms` in product UI.** "A 180ms dropdown feels more responsive than a 400ms one." The `200–500ms` modal/drawer band above is not a licence: the `300–500ms` half of it belongs to full-viewport travel — a mobile sheet crossing the whole screen — and everything else lives under `300ms`. *Exception:* marketing and explanatory motion, which obeys `marketing-pages`, not this ceiling.60614. **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: center` is correct there, and reporting it is a worse error than missing it.62635. **Press feedback is `scale(0.97)` on `:active`, as a transition at `160ms` ease-out.** A transition lets a release mid-press glide back instead of snapping; keep the value in `0.95–0.98`, since below `0.95` it turns cartoonish. *Exception:* dense or high-frequency controls skip it — bake an opt-out prop into the Button rather than removing the transition.64656. **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.66677. **Exits run 20–30% faster than enters, and travel less.** The user has already decided to leave. Enter `250ms` / exit `180ms`, nudging `translateY(-8px)` rather than the element's own height, and never cutting to `display: 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.68698. **Animate `transform` and `opacity` only.** Everything else forces layout or paint on the whole subtree; `transition: all` is a finding on sight. *Exception:* `clip-path` and `filter: blur()` are legitimate and hardware-friendly, with blur capped under `20px` because it is expensive, especially in Safari — see [techniques.md](references/techniques.md).70719. **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 the `AnimatePresence` wrapper for toggles, tabs, and icon swaps. *Exception:* a genuine first-run entrance depends on its `initial` prop — `initial={false}` there deletes the whole thing.727310. **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.7475## Smell / Fix7677| Smell | Fix |78| --- | --- |79| `transition: all 400ms ease-in` | Name the properties, `200ms`, `var(--ease-out)` |80| Enter from `scale(0)` or `translateY(100vh)` | `scale(0.95)` + `opacity: 0`, short travel |81| Animating `height`, `width`, `margin`, `top` | `transform` + `opacity`; `grid-template-rows` or `clip-path` for expansion |82| Popover scales from centre | `transform-origin: var(--transform-origin)` |83| Modal and overlay on different durations | Paired elements share duration and curve exactly |84| `@keyframes` on an open/close toggle | A `transition`, so it can be interrupted |85| Motion on arrow-key navigation | Remove it — keyboard-initiated actions never animate |86| Exit longer or louder than the enter | Exit 20–30% faster, `translateY(-8px)` |87| Hover state flickers | Animate a child so the parent's hover area stays still |88| 1px jitter at the start or end | `will-change: transform` on that element only |89| No reduced-motion path | Add it in the same commit, not the next one |90| A new dependency for a fade | Use the project's existing stack |9192## Output9394Ship 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.9596## Checklist9798- [ ] Frequency gate passed, and the animation's purpose is nameable in one word99- [ ] `transform` / `opacity` only; no `transition: all`100- [ ] Curve matches enter / move / hover / linear, and is a project token101- [ ] Duration under `300ms` (or justified full-viewport travel), exit faster than enter102- [ ] Enters from `scale(0.95)`–`0.97`, never `scale(0)`103- [ ] Origin-aware if it is anchored to a trigger; centred if it is a modal104- [ ] Reversible state uses a `transition`, not `@keyframes`105- [ ] `:active` feedback present unless the control is high-frequency106- [ ] `prefers-reduced-motion` path exists and keeps opacity response107- [ ] Written in the project's existing motion system, not beside it