Motion
Seven durations, five curves. Motion exists to explain what changed and where it went — never to prove that the team can animate.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The five curves
Click any card to replay. The graph is the curve; the track underneath is what it feels like.
The duration scale
Pick by what is moving and how far, not by what feels nice in isolation.
--duration-instant75msColour change on a control the pointer is already over.--duration-fast120msHover, press, focus. The default for feedback.--duration-quick160msTooltips, small fades, icon rotation.--duration-normal220msPopovers, dropdowns, accordions, tab panels.--duration-slow320msDialogs, drawers, anything crossing a large distance.--duration-slower480msFull-screen transitions, onboarding sequences.--duration-deliberate640msDeliberate emphasis. One per flow, at most.Duration scales with distance
Both boxes use the same easing. The short one takes 160ms and the long one 320ms, and they arrive feeling like the same speed.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
cubic-bezier(0.2, 0, 0, 1) — our standard curve. Both control points are pulled hard toward the end, which is what produces the fast departure and long settle.
- Duration220ms
The total time. Chosen from the distance travelled, not from taste. 1ms per 2px of movement is the working heuristic.
- First control point0.2, 0
Low y value means the animation leaves the start position immediately. This is what makes an interface feel responsive rather than laggy.
- Second control point0, 1
Pulled to the top-left, so the last 20% of the distance takes 40% of the time. The settle is what reads as "physical".
- Animated propertytransform / opacity
Only these two are composited. Animating width, height, top or margin runs layout on every frame and drops below 60fps on a mid-range phone.
- Reduced-motion fallback~1ms, not 0
Collapsed rather than removed, so transitionend still fires. Setting it to 0 breaks any state machine that waits for the event.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-instant | Colour change under an already-hovering pointer | |
| --duration-fast | Hover, press, focus feedback | |
| --duration-quick | Tooltips, small fades, chevron rotation | |
| --duration-normal | Popovers, dropdowns, accordions | |
| --duration-slow | Dialogs, drawers, long travel | |
| --duration-slower | Full-screen transitions | |
| --ease-standard | The default for everything | |
| --ease-emphasized | Surfaces entering or leaving | |
| --ease-decelerate | Entering the viewport | |
| --ease-accelerate | Leaving the viewport | |
| --ease-spring | Overshoot on toggles and success states |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | When to use |
|---|---|---|
| Micro | 75–120ms | State change on an element already under the pointer. Colour, opacity, border. |
| Small | 160ms | Travel under ~20px, or a fade with no movement. Tooltips, icon rotation. |
| Medium | 220ms | Travel of 20–150px. Popovers, dropdowns, accordion panels. |
| Large | 320ms | Travel of 150–400px. Dialogs, drawers, bottom sheets. |
| Extra large | 480ms | Full-viewport travel. Page transitions, fullscreen dialogs. |
| Looping | 720ms – 1.6s | Spinners at 720ms, shimmer at 1.6s, indeterminate bars at 1.4s. |
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; }
}transition: height 300ms, margin-top 300ms;Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Motion must not be the only way a state change is communicated. If a row slides in to indicate "new", it also needs a label or a colour.
- Never flash anything more than three times per second. Beyond that it is a documented seizure trigger.
Keyboard
| Esc | Must interrupt an in-progress enter animation and dismiss immediately — never wait for the animation to finish. |
| Tab | Focus must land on the destination even if the animation is still running; do not gate focus on animationend. |
Screen readers
- Screen readers announce DOM changes immediately, ignoring animation. Content that fades in over 300ms is announced at 0ms, so make sure the DOM state is correct from the start.
- Do not delay adding content to the DOM in order to sequence an animation; use CSS animation-delay instead.
Focus & touch
- Focus must never be trapped by an animation. If a dialog animates in over 320ms, focus moves at 0ms — the user should be able to type before the animation finishes.
- Touch interactions need faster feedback than pointer ones — there is no hover state to pre-signal the press. Keep touch feedback at or under 100ms.
| Attribute | Applied to | Notes |
|---|---|---|
| prefers-reduced-motion: reduce | Global media query | Set by the OS. Roughly 5–10% of users have it on. Collapse durations, remove parallax and auto-play, keep opacity fades — they do not trigger vestibular symptoms. |
| aria-live | Animated status changes | A screen-reader user gets none of the animation. Any meaning it carries must also be announced. |
| animation-play-state | Looping decoration | Anything that loops for more than five seconds needs a mechanism to pause it — WCAG 2.2.2. |
Example usage
1// Transitions: name the properties explicitly, never "all"2<button className="transition-[background-color,box-shadow,transform] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)]" />34// Enter animations come from the keyframe tokens5<div className="animate-[scale-in_180ms_cubic-bezier(0.32,0.72,0,1)_both] origin-top-left" />67// Duration derived from distance8function durationFor(px: number) {9 return Math.round(Math.min(480, Math.max(120, px / 2)))10}1112// Reading the user's preference in JS, for canvas or WebGL13const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches1415// The View Transitions API for cross-view continuity16if (document.startViewTransition) {17 document.startViewTransition(() => setRoute(next))18} else {19 setRoute(next)20}CSS
:root {
--duration-instant: 75ms;
--duration-fast: 120ms;
--duration-quick: 160ms;
--duration-normal: 220ms;
--duration-slow: 320ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1);
--ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--ease-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Name the properties. "all" makes the browser watch everything. */
.control {
transition:
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
/* Enter and exit are mirror images */
@keyframes scale-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@keyframes scale-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.96); }
}
/* Collapse, do not remove — transitionend must still fire */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Professional tips
- If you cannot articulate what an animation explains, delete it. "It looks nice" is not a reason that survives the fiftieth viewing.
- Test every animation at 4× CPU throttling in DevTools. Anything that stutters there stutters on a real mid-range Android.
- Stagger list items by 20–30ms, not 100ms. Ten items at 100ms is a full second before the last one lands.
- For accordions, animate grid-template-rows from 0fr to 1fr instead of height auto. It is a single property, it is animatable, and it needs no JavaScript measurement.
Performance
- Composited properties are transform, opacity and filter. Everything else touches layout or paint on the main thread.
- will-change promotes an element to its own GPU layer. Add it just before the animation and remove it after — leaving it on permanently consumes GPU memory for no benefit.
- The Web Animations API runs off the main thread for transform and opacity, which makes it a better choice than requestAnimationFrame loops for anything continuous.
- One 300ms animation on a 1200px-wide element costs less than fifty 300ms animations on 24px elements. Batch where you can.
Common mistakes
- Using transition: all. The browser watches every animatable property, and an unrelated class change causes an unexpected animation.
- Forgetting a transform-origin, so a scale-in appears to come from the centre of the screen instead of from its trigger.
- Animating an element into view while its content is still loading. The user watches an empty box slide in and then fill.
- Setting reduced-motion durations to 0 instead of 0.01ms. transitionend never fires and any state machine waiting on it hangs forever.
Real-world recommendations
- Instrument your slowest animation with the Long Animation Frames API in production. Local performance on a dev machine tells you almost nothing.
- When a stakeholder asks for "more animation", the productive response is usually better loading states — that is the moment where motion genuinely helps.
- Match your platform. Native iOS uses a spring; Material uses emphasized-decelerate. A web app that borrows the wrong one feels subtly foreign on that device.
- Keep a single page that renders every animation in the system side by side. It is the fastest way to spot the one that drifted to 500ms during a rush.