Skip to content

Motion

Seven durations, five curves. Motion exists to explain what changed and where it went — never to prove that the team can animate.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Feel it

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.

6px travel · 160ms
200px travel · 320ms

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.

Hover120ms standard
Press120ms, scale 0.985
Enter180ms scale-in
Slide up220ms emphasized
Spin720ms linear
Shimmer1.6s linear loop
Indeterminate1.4s standard
respects OS setting
ReducedCollapses to ~1ms

Anatomy

Every part, every measurement, and the reason it is that number.

0ms220ms01

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.

  1. Duration220ms

    The total time. Chosen from the distance travelled, not from taste. 1ms per 2px of movement is the working heuristic.

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

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

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

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Motion

TokenValueUsed for
--duration-instantColour change under an already-hovering pointer
--duration-fastHover, press, focus feedback
--duration-quickTooltips, small fades, chevron rotation
--duration-normalPopovers, dropdowns, accordions
--duration-slowDialogs, drawers, long travel
--duration-slowerFull-screen transitions
--ease-standardThe default for everything
--ease-emphasizedSurfaces entering or leaving
--ease-decelerateEntering the viewport
--ease-accelerateLeaving the viewport
--ease-springOvershoot on toggles and success states

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightWhen to use
Micro75–120msState change on an element already under the pointer. Colour, opacity, border.
Small160msTravel under ~20px, or a fade with no movement. Tooltips, icon rotation.
Medium220msTravel of 20–150px. Popovers, dropdowns, accordion panels.
Large320msTravel of 150–400px. Dialogs, drawers, bottom sheets.
Extra large480msFull-viewport travel. Page transitions, fullscreen dialogs.
Looping720ms – 1.6sSpinners at 720ms, shimmer at 1.6s, indeterminate bars at 1.4s.

Do

transform: translateY(8px)opacity: 0 → 1height: 0 → automargin-top: −8px → 0
Animate only transform and opacityBoth are handled by the compositor and never trigger layout or paint. Everything else runs on the main thread and competes with your JavaScript for frame budget.
in: scale 0.96 → 1out: 1 → 0.96
Match the exit animation to the entranceA panel that grows out of a button should shrink back into it. Reversing the same transform is what makes the surface feel like one persistent object rather than two unrelated events.
@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; }
}
Respect prefers-reduced-motionVestibular disorders make large movement genuinely nauseating. Collapse durations to ~1ms rather than removing transitions, so any code awaiting transitionend still works.
TriggerMenu
Set the transform-origin to the triggerA menu that scales from its top-left corner appears to grow out of the button that opened it. The same animation from centre reads as a surface that materialised out of nowhere.

Don't

Do not animate anything over 400ms that the user initiatedThe user pressed a button and is waiting. Past ~400ms the animation stops being an explanation and becomes a delay, and people start pressing again.
transition: height 300ms, margin-top 300ms;
Do not animate layout propertiesheight, width, top, left, margin and padding all force a layout recalculation of the subtree on every frame. On a mid-range phone this is the difference between 60fps and 22fps.
Do not use spring overshoot as a defaultAn overshoot is a small surprise. One per flow is delightful; on every hover it reads as an interface that cannot sit still, and it makes precise pointing harder.
request 900ms + fade 300ms = 1200ms of nothing
Do not animate content that is already lateData arrives after a 900ms request and then fades in over 300ms. The user has now waited 1.2 seconds. Late content should appear instantly — the wait was the animation.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

2.3.3Animation from InteractionsAAA2.2.2Pause, Stop, HideA2.3.1Three Flashes or BelowA

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

EscMust interrupt an in-progress enter animation and dismiss immediately — never wait for the animation to finish.
TabFocus 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.
AttributeApplied toNotes
prefers-reduced-motion: reduceGlobal media querySet 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-liveAnimated status changesA screen-reader user gets none of the animation. Any meaning it carries must also be announced.
animation-play-stateLooping decorationAnything that loops for more than five seconds needs a mechanism to pause it — WCAG 2.2.2.

Code

Example usage

tsx
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

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;
  }
}

Notes

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.