Animation
Six named patterns built from the motion tokens. If an animation in the product is not one of these, it needs a reason.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
No height measurement, no JavaScript, animates to auto.
Stagger
Same animation, same duration, two different delays per item. The left reads as one thing; the right reads as waiting.
Micro-interactions
The smallest useful animations. Each one confirms an action at the exact place the user is already looking.
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.
opacity 160ms standardContent replacing content in the same position. The cheapest transition and the safest under reduced motion.
opacity + scale 0.96→1, 180ms emphasizedAnchored surfaces: popovers, menus, tooltips. Set transform-origin to the trigger corner.
translateY 8px→0 + fade, 220ms emphasizedToasts and content arriving from below. The 8px is enough to read as direction without being a journey.
translate 100%→0, 260–320ms emphasizedDrawers and bottom sheets. Always from the edge they are anchored to.
grid-template-rows 0fr→1fr, 220ms standardAccordions and expanding rows. Animates to auto height with no JavaScript measurement.
scale 1→1.04→1, 320ms springA value the user did not change just changed. Once, never looping.
The complete vocabulary. Anything not on this list needs a written justification.
- Fadeopacity 160ms standard
Content replacing content in the same position. The cheapest transition and the safest under reduced motion.
- Scale inopacity + scale 0.96→1, 180ms emphasized
Anchored surfaces: popovers, menus, tooltips. Set transform-origin to the trigger corner.
- 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.
- Edge slidetranslate 100%→0, 260–320ms emphasized
Drawers and bottom sheets. Always from the edge they are anchored to.
- Collapsegrid-template-rows 0fr→1fr, 220ms standard
Accordions and expanding rows. Animates to auto height with no JavaScript measurement.
- Attentionscale 1→1.04→1, 320ms spring
A value the user did not change just changed. Once, never looping.
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 |
|---|---|---|
| fade-in | Content swap in place | |
| scale-in | Anchored popovers and menus | |
| slide-up | Toasts and arriving content | |
| drawer-in-right / -left | Drawers | |
| sheet-in | Bottom sheets | |
| shimmer | Skeletons | |
| indeterminate | Unknown-length progress | |
| pulse-ring | Live status dots | |
| --ease-emphasized | — | Every enter and exit |
| --ease-standard | — | Collapse, fade, property transitions |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | When to use |
|---|---|---|---|
| Fade | 160ms | — | Content replacing content. No movement, so it is always reduced-motion safe. |
| Scale in | 180ms | — | Popovers, menus, tooltips. Origin must match the trigger. |
| Slide up | 220ms | 8px travel | Toasts, inline additions, arriving rows. |
| Edge slide | 260–320ms | 100% travel | Drawers and sheets. |
| Collapse | 220ms | — | Accordions, expanding table rows, disclosure panels. |
| Attention | 320ms | — | One pulse on an externally-changed value. Never loops. |
| Stagger delay | 20–30ms | — | Per item, capped at about eight items. |
display: grid;
grid-template-rows: 0fr → 1fr;
> div { overflow: hidden }@media (prefers-reduced-motion: reduce) {
.slide-up { animation-name: fade-in }
}Not a checklist to run at the end. These are the requirements the component was built from.
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
| Esc | Cancels an in-progress enter animation and dismisses immediately. |
| Tab | Focus 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.
| Attribute | Applied to | Notes |
|---|---|---|
| prefers-reduced-motion | Every pattern | Translate and scale become a fade. Loops stop. Parallax and auto-advancing carousels are disabled entirely. |
| aria-live="polite" | Animated arrivals | A row that slides in must also be announced; screen-reader users get none of the motion. |
| animation-play-state | Loops over 5s | WCAG 2.2.2 requires a pause mechanism for anything that moves for more than five seconds. |
Example usage
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
@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; }
}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.