Choreographing Multi-Element Motion

Part of Motion Design Systems & Tokens in Core CSS Animation Fundamentals.

The problem

A settings dialog opens. The backdrop fades in, the dialog scales up, the title slides down, the form fields cascade in from the left, the footer buttons pop up from below and a toast in the corner announces that settings were loaded. Each animation was designed by a different person for a different component, and each looks fine alone. Together, the screen feels chaotic: the eye does not know where to look, the dialog seems to take forever to become usable, and the whole thing costs a burst of layer promotions on a single frame.

Choreography is the set of decisions about how motions relate to each other. Without it, a design system of individually good animations produces collectively bad screens.

Root cause analysis: attention can follow one thing

One lead, several followers. Human attention tracks one moving thing well and several poorly. Good choreography picks a lead motion — usually the surface that is changing, such as the dialog — and makes everything else follow it: starting slightly later, travelling less, and settling in its direction. Followers reinforce the lead instead of competing with it.

Containers before contents. A surface should arrive before its contents appear. Contents moving inside a container that is itself still moving produces compound motion that is hard to read. Letting the container reach most of its final position, then fading or lightly offsetting its children, keeps each motion legible.

Stagger has a budget. Staggering children makes a group read as a sequence, but the total wait grows with the number of items. A fixed budget — for example, the last item must start within 150ms of the first — keeps the UI responsive. The technique is covered in staggering animations with sibling-index(); the budget itself is a choreography token.

Direction and easing should agree. If the dialog rises, its fields should not slide in from the left. Related elements share a direction and an easing role so they read as one event.

Unrelated motion waits. A toast announcing a background event should not animate during a dialog’s entrance. Queue or delay secondary notifications until the primary transition settles.

Performance follows choreography. Starting everything on the same frame promotes many layers and uploads many textures at once, the problem analysed in budgeting concurrent animations per frame. A choreography that sequences container then contents spreads that work naturally.

Choreographed dialog entranceLead first, followers overlap its tail, total stagger capped at 120 ms.Choreographed dialog entranceBackdrop (fade)fadeidle340 msDialog (lead)rise + scalesettled340 msContent groupwaitfade, staggered340 msfadeidlerise + scalesettledwaitfade, staggered
Lead first, followers overlap its tail, total stagger capped at 120 ms.

Step-by-step resolution

Choreographing a composite entranceDecide roles first; timing follows from roles.Choreographing a composite entrance1Name the lead motion and list every other animated element.One lead, several followers2Give followers a delay of about half the lead's duration.Contents appear as the surface settles3Reduce followers' travel to a fraction of the lead's.Motion reinforces, not competes4Cap follower stagger with a token such as --motion-stagger-budget.Predictable total time5Defer unrelated notifications until the lead finishes.One focus of attention6Under reduced motion, fade everything together without delays.
Decide roles first; timing follows from roles.

Production code pattern

:root {
  --motion-follow-delay: calc(var(--motion-enter-duration) * 0.55);
  --motion-follow-distance: calc(var(--motion-enter-distance) * 0.5);
  --motion-stagger-step: 30ms;
  --motion-stagger-budget: 120ms;
}

.dialog[open] {
  animation: lead-in var(--motion-enter-duration) var(--motion-enter-easing) both;
}
.dialog[open] > .dialog__content > * {
  animation: follow-in var(--motion-enter-duration) var(--motion-enter-easing) both;
  animation-delay: calc(
    var(--motion-follow-delay) +
    min(calc((sibling-index() - 1) * var(--motion-stagger-step)), var(--motion-stagger-budget))
  );
}
.dialog[open]::backdrop {
  animation: fade-in var(--duration-300) linear both;
}

@keyframes lead-in {
  from { opacity: 0; translate: 0 var(--motion-enter-distance); scale: 0.98; }
}
@keyframes follow-in {
  from { opacity: 0; translate: 0 var(--motion-follow-distance); }   /* same direction, less travel */
}
@keyframes fade-in { from { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .dialog[open],
  .dialog[open] > .dialog__content > *,
  .dialog[open]::backdrop {
    animation: fade-in 120ms linear both;
    animation-delay: 0s;                                  /* everything together */
  }
}
// Secondary notifications wait for the primary transition.
async function notifyAfterDialog(dialog, message) {
  await Promise.allSettled(dialog.getAnimations({ subtree: true }).map((a) => a.finished));
  showToast(message);
}

Rendering Impact: composite. Every effect animates opacity, translate and scale; the choreography spreads their starts across roughly 260ms so layer promotion and uploads are not concentrated on one frame.

sibling-index() is new; where it is unsupported the whole animation-delay declaration is invalid and the follower delay falls back to 0s. For broad support, set a --i index in markup and use it in place of sibling-index() - 1, as in the stagger guide.

Uncoordinated against choreographedSame elements, same animations; different relationships.Uncoordinated against choreographedEvery component animates itselfTitle slides down, fields slide right, buttons popupToast enters at the same momentAll effects start on frame oneCompeting motionLead and followersDialog rises; contents follow upward with lesstravelStagger capped at 120 msToast waits for the dialog to settleOne readable event
Same elements, same animations; different relationships.

Encoding choreography in the system

Choreography rules become consistent only when they are tokens and shared keyframes rather than guidance in a document. The follower delay ratio, follower distance ratio, stagger step and stagger budget above are four tokens that every composite component can reuse. Pair them with a small set of shared keyframes — lead-in, follow-in, fade-in — and a composite component needs only to mark which elements lead and which follow. Document the rule set alongside the duration scale in the token scale reference, with one annotated example per common pattern: dialog, drawer, list, and page transition.

Verification checklist

Constraints and trade-offs

  • Follower delays make content appear later; keep them short enough that the dialog is usable quickly.
  • Focus should move into a dialog when it opens, not when choreography finishes.
  • Tokens derived with calc() from other tokens are harder to inspect in DevTools.
  • Choreography across independently developed components needs shared conventions, not just shared tokens.
  • Very long lists should animate only the visible items and cap the stagger regardless of count.

Frequently asked questions

Should all elements in a group animate at the same time?

Usually not. A lead element followed by related elements with small delays and less travel reads as one coherent event. Everything at once competes for attention.

Should a container animate before its contents?

Yes. Let the surface reach most of its final position before its contents appear, so motions are not compounded.

How long should a stagger be?

Keep individual steps around 30 to 50ms and cap the total so the last item starts within about 100 to 150ms of the first.

How does choreography change with reduced motion?

Remove delays, travel and staggers, and fade the group in together with a single short duration.