Choreography
intermediate·9 min·Markdown for agents
What this lesson teaches
Make several things move together with intent, using stagger, travelling elements, clip-path reveals and grouped hover.
Slide the delay to 150ms and press replay. The last row arrives so late it feels like the search is still loading. Now try 40ms.
- Pricing page redesign
- Onboarding checklist
- Billing settings
- Team invites
- Usage dashboard
- Export to CSV
- API keys
- Audit log
A is the same entrance played on several items, each a little after the last. Done well it shows order and pulls the eye down the list. Done badly it makes a fast app feel slow.
Stagger: small gaps, short cascade
- 30–80ms between items. Under 30 looks like everything at once with extra steps. Over 80 starts to feel like loading.
- Keep the whole cascade under about 300ms. Past around a dozen items, cap it: items past the cap arrive with the last staggered one.
- Only on first appearance. The first search result list can cascade; refining the search, sorting or filtering should not re-stagger. Update in place.
- Only when order means something. A stagger implies a reading order. Identical items in arbitrary order shouldn't pretend to have one.
- Exit together. If you must stagger an exit, reverse it; usually a faster, single fade is better.
For a page or hero section, split it into heading, text and button and offset them ~90–100ms, each entering with opacity, translateY(8px) and a 2px blur. And remember that first paint is not an entrance: what's already on screen when the page loads should simply be there.
const delay = (i: number) => Math.min(i, 12) * 0.05; // 50ms apart, capped at 12Fly, don't teleport
Benji Taylor's rule from the Family app: if something is on screen before and after a change, it should travel, not disappear and reappear. Click between tabs that are far apart in each mode.
Click between tabs far apart. When the pill flies, your eye follows it and you know where you went.
When the pill teleports, your eye has to search for where it went. When it flies, you watch it get there. The same goes for a list item moving to a new position, a card expanding into a full screen, or a stacked modal: the one underneath recedes slightly (scale 0.96, a little blur) because it's coming back, rather than vanishing.
The technique behind most of these is called : measure where the element is (First), let it move to its new spot (Last), shift it back with a transform so it looks unmoved (Invert), then transition the transform away (Play). Motion's layoutId does this for you. Crossfading is still right when two things are genuinely different content; forcing travel between unrelated cards is decoration.
clip-path: the underrated one
cuts away part of an element, and it animates smoothly and cheaply. inset(top right bottom left) clips from each edge; both ends of a transition must use the same shape function. Hold this button down.
.fill { clip-path: inset(0 100% 0 0); transition: clip-path 200ms var(--ease-out); } /* release: fast */
.button:active .fill { clip-path: inset(0 0 0 0); transition: clip-path 2s linear; } /* hold: slow */The press is slow and linear because it's progress and you're deciding; the release answers in 200ms. Put a single duration on the base rule and an aborted hold crawls back for two whole seconds.
Other patterns from Emil Kowalski's notes:
- Tabs whose text flips color exactly under a sliding pill: two text layers, the top one clipped to the pill.
- Image reveals on scroll:
inset(20% 0 20% 0)toinset(0), like a letterbox opening. - Before/after sliders: the "after" image clipped by a CSS variable that follows the pointer.
- Directional swaps: one panel clips out to the left as the next clips in from the right, with no layout shift.
Combine it with rounded corners using inset(… round 12px) rather than border-radius, which double-clips in Safari.
Grouped hover: the falloff
Hover or tap a face in this stack. Its neighbours lift too, a little less with each step away.
From Jakub Antalik's avatar group: shift = lift × falloff^distance, with the same idea for scale. With a lift of −8px and a falloff of 0.45, the neighbour moves −3.6px, the next −1.6px, and by four away it's under a pixel. 0.4–0.5 is the sweet spot: 0.8 drags the whole row along, 0.2 is just one face. The lift uses ease-out; the return uses a slight overshoot, so it settles with life. Skip it on dense data tables and lists over ~20 items, and note it can't fire on touch-only screens without a tap.
Stagger the search results only on their first appearance: 40ms between rows, capped so the whole cascade stays under 300ms, opacity plus translateY(8px), ease-out. Don't re-stagger on filter or sort. Make the active tab indicator one persistent element that slides between tabs (FLIP, layoutId, or clip-path inset on a full-width pill) with cubic-bezier(0.77, 0, 0.175, 1) over ~300ms, instead of re-rendering under each tab.
Quick check
A results list staggers 120ms per row across 15 rows every time the user changes a filter. What's wrong?
In the skill
The nodes your agent reads when it does this work.
- Stagger choreographyCascading reveals (30–80ms delays). When stagger helps comprehension and when it just reads as noise.
motion/stagger-choreography - Fly, don't teleportWhen a component persists between two states, animate the change. Don't crossfade what should travel.
motion/fly-not-teleport - clip-path tricksclip-path is animatable, GPU-accelerated, and unlocks 5+ patterns that would otherwise require canvas or SVG.
motion/clip-path-tricks - Distance-falloff hover propagationPer-element lift via `lift * pow(falloff, distance)` for grouped hover. Avatar groups, list rows.
motion/distance-falloff-propagation
Connected lessons
What this lesson builds on and leads to, across every course.