Motion
Generated from DESIGN_SYSTEM.md
Rules
Motion explains a change; it never decorates one. Something moves because it entered, left, moved or responded, and the motion shows where it came from and where it went. Nothing moves on its own except a loading indicator and a designated ambient canvas (Motion canvas below).
Duration scale — four steps
| Step | Web | Use |
|---|---|---|
| 150ms | duration-150 | Color and opacity on small controls: a pressed button, a toggled chip |
| 200ms | duration-200 | Inputs and hover: focus ring, field border, row highlight |
| 300ms | duration-300 (the default) | Surfaces: a card lifting, a dialog or sheet opening, the sidebar drawer |
| 500ms | duration-500 | Large reveals: a page-level section or a hero entering |
- The default is 300ms. Pick a step from the table; a value off the scale (
duration-[250ms],duration-700on a control) needs a reason written beside it. - Leaving is faster than entering. A dismissed surface uses the step below the one it opened with, so the interface never waits on its own exit.
- Longer than 500ms is ambient only: marquee, skeleton shimmer, a progress meter filling to its value. Never for something the person is waiting to use.
Easing — three curves
| Curve | Web | Use |
|---|---|---|
| Ease out | ease-out (the default) | Anything entering or responding to input: it starts fast and settles |
| Ease in and out | ease-in-out | Something moving between two resting places: a drawer, a sheet, a tab indicator |
| Linear | ease-linear | Continuous loops only: a spinner, a marquee |
Bare ease-in is not used for interface motion: it starts slowly, which reads as lag.
Platforms
| Platform | Default | Rule |
|---|---|---|
| Web | transition-* duration-300 ease-out | Entrance and exit animations are real transitions or framer-motion; a class that generates no CSS is not motion |
| iOS | .snappy(duration: 0.25, extraBounce: 0) for controls; .spring(response: 0.32, dampingFraction: 0.88) for surfaces | System presentation (sheets, alerts, navigation) keeps its own motion |
| Android | Material 3 motion defaults (tween/spring from Compose) | System presentation keeps its own motion |
Reduced motion
- Every non-essential animation stops under reduced motion —
prefers-reduced-motionon the web (useReducedMotion()ormotion-safe:), Reduce Motion on iOS (accessibilityReduceMotion), animator duration scale 0 on Android. - A moving surface becomes a cross-fade or an instant change; a loop stops on a still frame; a skeleton stays at rest.
- Motion never carries meaning on its own: a state it announces is also stated in text, icon or color.
Motion canvas — decorative and waiting motion
Abstracted from reviewing thinking-orbs (Jakub Antalik, MIT), the reference for "free, but inside a flow" dots in Apple's setup screens. Motion that runs on its own — a brand background, an AI waiting state — is cheap by construction:
- Plain 2D canvas. No WebGL, no
ctx.filter, no SVG filters: the same pixels in every browser, cheap on low-end phones. Device pixel ratio capped at 2. - It stops when nobody sees it. Paused offscreen (IntersectionObserver) and in a hidden tab; instances share one clock and resume in phase.
- Reduced motion gets a still frame, never an empty box.
- Ink from the page:
currentColoror the page theme, never a color literal in the canvas code. - One component per job: the brand surface is
BrandDotsCanvas; an AI waiting state isThinkingIndicator(thinking-orbs, phase from the server task status, never a timer). A new moving surface extends one of them before it adds a third.
Live specimens
duration-150Pressed button, toggled chipduration-200Focus ring, hover, field borderduration-300Surfaces: the defaultduration-500Large reveals
ease-outease-in-outease-linear