Skip to content

Animation

Six named patterns built from the motion tokens. If an animation in the product is not one of these, it needs a reason.

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.

Patterns
Fade160ms standard
content
Scale in180ms emphasized, origin top-left
triggerpopover
Slide up220ms emphasized
Saved
Edge slide260ms emphasized
Collapsegrid-template-rows 0fr → 1fr

No height measurement, no JavaScript, animates to auto.

AttentionOne pulse, never looping
3

Stagger

Same animation, same duration, two different delays per item. The left reads as one thing; the right reads as waiting.

25ms staggerReads as one group arriving
140ms stagger700ms before the last row lands

Micro-interactions

The smallest useful animations. Each one confirms an action at the exact place the user is already looking.

hover
icon swap
rotate
lift

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.

Enterscale-in 180ms
ExitReverse at 75%
Fade160ms
Slide up220ms, 8px
Spin720ms linear loop
Pulse ring1.8s, live status
Shimmer1.6s, skeleton
no translate
ReducedFades only

Anatomy

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

1
Fadeopacity 160ms standard

Content replacing content in the same position. The cheapest transition and the safest under reduced motion.

2
Scale inopacity + scale 0.96→1, 180ms emphasized

Anchored surfaces: popovers, menus, tooltips. Set transform-origin to the trigger corner.

3
Slide uptranslateY 8px→0 + fade, 220ms emphasized

Toasts and content arriving from below. The 8px is enough to read as direction without being a journey.

4
Edge slidetranslate 100%→0, 260–320ms emphasized

Drawers and bottom sheets. Always from the edge they are anchored to.

5
Collapsegrid-template-rows 0fr→1fr, 220ms standard

Accordions and expanding rows. Animates to auto height with no JavaScript measurement.

6
Attentionscale 1→1.04→1, 320ms spring

A value the user did not change just changed. Once, never looping.

The complete vocabulary. Anything not on this list needs a written justification.

  1. Fadeopacity 160ms standard

    Content replacing content in the same position. The cheapest transition and the safest under reduced motion.

  2. Scale inopacity + scale 0.96→1, 180ms emphasized

    Anchored surfaces: popovers, menus, tooltips. Set transform-origin to the trigger corner.

  3. Slide uptranslateY 8px→0 + fade, 220ms emphasized

    Toasts and content arriving from below. The 8px is enough to read as direction without being a journey.

  4. Edge slidetranslate 100%→0, 260–320ms emphasized

    Drawers and bottom sheets. Always from the edge they are anchored to.

  5. Collapsegrid-template-rows 0fr→1fr, 220ms standard

    Accordions and expanding rows. Animates to auto height with no JavaScript measurement.

  6. Attentionscale 1→1.04→1, 320ms spring

    A value the user did not change just changed. Once, never looping.

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
fade-inContent swap in place
scale-inAnchored popovers and menus
slide-upToasts and arriving content
drawer-in-right / -leftDrawers
sheet-inBottom sheets
shimmerSkeletons
indeterminateUnknown-length progress
pulse-ringLive status dots
--ease-emphasized—Every enter and exit
--ease-standard—Collapse, fade, property transitions

Recommended sizes

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

SizeHeightLabel gapWhen to use
Fade160ms—Content replacing content. No movement, so it is always reduced-motion safe.
Scale in180ms—Popovers, menus, tooltips. Origin must match the trigger.
Slide up220ms8px travelToasts, inline additions, arriving rows.
Edge slide260–320ms100% travelDrawers and sheets.
Collapse220ms—Accordions, expanding table rows, disclosure panels.
Attention320ms—One pulse on an externally-changed value. Never loops.
Stagger delay20–30ms—Per item, capped at about eight items.

Do

display: grid;
grid-template-rows: 0fr → 1fr;
> div { overflow: hidden }
Animate collapse with grid-template-rowsFrom 0fr to 1fr animates to intrinsic height with no measurement, no JavaScript, and no jump at the end. It replaced every max-height hack overnight.
in 220msout 160ms
Mirror the exit, slightly fasterEntering is an introduction and can afford to settle. Leaving should feel decisive — about 75% of the enter duration is the ratio that reads as intentional.
Cap the stagger at about eight itemsBeyond eight the tail is arriving long after the user has started reading the head. Animate the first eight and let the rest appear instantly.
@media (prefers-reduced-motion: reduce) {
  .slide-up { animation-name: fade-in }
}
Keep fades under reduced motionOpacity does not trigger vestibular symptoms. Removing all animation makes the interface feel broken; removing translation and scale is what the setting is actually asking for.

Don't

9
Do not loop an attention animationA pulse that never stops is impossible to ignore and impossible to dismiss. It stops being information and becomes an irritant within about four seconds.
Do not animate list re-renders during filteringEvery keystroke restarts every item animation. The list strobes, and the user cannot read the results they are actively narrowing down.
triggermenu
Do not scale from the centre for anchored surfacesA menu that grows from its own centre appears out of nowhere. The user loses the link back to the control they just pressed.
skeleton 220ms → data 200ms → total 420ms of theatre
Do not animate an element into a loading stateThe skeleton slides in, then the data arrives and fades in on top. The user watches two animations to see one piece of content.

Accessibility

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

2.2.2Pause, Stop, HideA2.3.1Three Flashes or BelowA2.3.3Animation from InteractionsAAA

Contrast

  • A shimmer must not exceed a 3:1 luminance swing, or it reads as a flash for photosensitive users.
  • Never animate a colour through a state that fails contrast, even briefly — text is readable at both ends and unreadable in the middle.

Keyboard

EscCancels an in-progress enter animation and dismisses immediately.
TabFocus lands on the destination at 0ms, regardless of how long the animation takes.

Screen readers

  • Animation is invisible to assistive tech. If the animation is the only signal that something arrived, nothing arrived.
  • Do not stagger DOM insertion to create a stagger effect — use animation-delay, so all content is announced at once.

Focus & touch

  • Never delay focus for an animation. The dialog is focusable the moment it is in the DOM; the animation is decoration running alongside.
  • Touch has no hover, so press feedback must be immediate. Keep any touch-triggered animation at or under 100ms before the visual state changes.
AttributeApplied toNotes
prefers-reduced-motionEvery patternTranslate and scale become a fade. Loops stop. Parallax and auto-advancing carousels are disabled entirely.
aria-live="polite"Animated arrivalsA row that slides in must also be announced; screen-reader users get none of the motion.
animation-play-stateLoops over 5sWCAG 2.2.2 requires a pause mechanism for anything that moves for more than five seconds.

Code

Example usage

tsx
1// Collapse to auto height, no JavaScript measurement2function Collapse({ open, children }) {3  return (4    <div5      className="grid transition-[grid-template-rows] duration-[220ms] ease-[cubic-bezier(0.2,0,0,1)]"6      style={{ gridTemplateRows: open ? '1fr' : '0fr' }}7    >8      <div className="overflow-hidden">{children}</div>9    </div>10  )11}1213// Stagger, capped so the tail never lags14{items.map((item, i) => (15  <li16    key={item.id}17    style={{ animationDelay: Math.min(i, 8) * 25 + 'ms' }}18    className="animate-[slide-up_220ms_cubic-bezier(0.32,0.72,0,1)_both]"19  >20    {item.label}21  </li>22))}2324// Exit animations need the element to stay mounted until they finish25const [leaving, setLeaving] = useState(false)26function close() {27  setLeaving(true)28  setTimeout(onClose, 160)   // matches the exit duration29}3031// Anchored surfaces grow out of their trigger32<Popover className="origin-top-left animate-[scale-in_180ms_var(--ease-emphasized)_both]" />

CSS

css
@keyframes fade-in  { from { opacity: 0 } to { opacity: 1 } }
@keyframes scale-in {
  from { opacity: 0; transform: scale(0.96) }
  to   { opacity: 1; transform: scale(1) }
}
@keyframes slide-up {
  from { opacity: 0; transform: translateY(8px) }
  to   { opacity: 1; transform: translateY(0) }
}
@keyframes attention {
  0%   { transform: scale(1) }
  45%  { transform: scale(1.06) }
  100% { transform: scale(1) }
}

/* Exit is the entrance reversed, at ~75% duration */
.leaving {
  animation: scale-in 140ms var(--ease-accelerate) reverse forwards;
}

/* Reduced motion: keep the fade, drop the movement */
@media (prefers-reduced-motion: reduce) {
  .slide-up, .scale-in, .sheet-in {
    animation-name: fade-in !important;
  }
  .shimmer, .pulse-ring { animation: none !important; }
}

Notes

Professional tips

  • Prototype animations at half speed. Anything that looks wrong at 0.5× is wrong at 1× too — you simply cannot see it yet.
  • The View Transitions API handles cross-page continuity with far less code than a hand-rolled FLIP implementation. Feature-detect it and fall back to an instant swap.
  • For exit animations, keep the node mounted for exactly the exit duration. A mismatch of even 40ms produces a visible flicker.
  • Reuse animation names across components. Six names that everyone recognises beat sixty bespoke keyframes nobody can audit.

Performance

  • CSS animations on transform and opacity run on the compositor and survive a busy main thread. A requestAnimationFrame loop does not.
  • animation-delay is free; a setTimeout per item is not. Stagger in CSS.
  • A shimmer on 50 skeleton elements is 50 simultaneous background-position animations. Animate one overlay across the group instead.
  • content-visibility: auto on offscreen list items stops their animations from being composited at all.

Common mistakes

  • Unmounting an element before its exit animation runs, so it vanishes instantly and the animation is dead code.
  • Using animation-fill-mode: forwards on an enter animation and then wondering why the element ignores later style changes.
  • Animating a skeleton and its replacement content, so the user watches two transitions for one piece of data.
  • Forgetting that animation restarts on re-render if the key changes — which is why a filtered list strobes.

Real-world recommendations

  • Record a screen capture at 240fps on a real device. Half the animation problems in a product are only visible in slow motion.
  • Keep an animation inventory page in the app. When someone adds a seventh pattern, the review conversation happens before it ships, not after it is in forty places.
  • For anything data-driven, animate the container, not the items. A table that re-sorts should crossfade once, not animate 200 rows individually.
  • When users say an app "feels slow", check the animation durations before the network waterfall. A 500ms transition on every navigation is a 500ms tax on every action.