# Motion

> 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`.

- Skill: `agentsorg/motion` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add agentsorg/motion`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/motion/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/motion

---



# 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](references/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](references/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):

```css
--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

1. **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.

2. **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.

3. **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.

4. **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.

5. **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.

6. **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.

7. **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.

8. **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).

9. **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.

10. **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` / `opacity` only; no `transition: 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`, never `scale(0)`
- [ ] Origin-aware if it is anchored to a trigger; centred if it is a modal
- [ ] Reversible state uses a `transition`, not `@keyframes`
- [ ] `:active` feedback present unless the control is high-frequency
- [ ] `prefers-reduced-motion` path exists and keeps opacity response
- [ ] Written in the project's existing motion system, not beside it

