Skip to content

Motion Orchestration

A UI principle for coding agents. Also covers orchestration, staggered animation, stagger delay, transition-delay, parent child animation sequence, animation-fill-mode both, and 10 more.

Show all 16 aliases

orchestration, staggered animation, stagger delay, transition-delay, parent child animation sequence, animation-fill-mode both, action-driven animation, data-action, different exit animations, squash and stretch, stretchy arrow, transform-origin center bottom, first visit animation, returning visitor, half-transparent text over text, animation feels mechanical

Core Philosophy

  • When several things move at once, the interesting decision is no longer easing or duration. It is order and relationship: what leads, what follows, how far behind, and whether they are even doing the same thing
  • Staging exists to prevent overlap, not to look choreographed. Half-transparent text sitting on half-transparent text is a real legibility failure, and a 125ms offset removes it
  • A state is not an animation. Several different actions can land on the same state, and each one can leave differently, which tells the user what they just did
  • Every relationship here is a number you tune by eye. The values in this doc are working starting points, not constants
  • Whether the motion is worth having at all belongs to animation-judgement. Spring tokens belong to spring-easing-in-css, hovers and boops to hover-and-boop-patterns, single-element entrances to reveal-techniques, and transform composition and fill modes to css-motion-mechanics

Staging a parent and its content

  • Fade the container first, then its content. Everything fading at once produces a few frames where the panel's text and the page text underneath are both at 50% opacity and stacked, which reads as a smear
  • The container is empty while it fades in, so nothing overlaps anything. By the time the content arrives, the surface behind it is opaque
  • Working numbers: parent 250ms, child 250ms, child delayed 125ms
  • Keep the child's delay between 30% and 70% of the parent's duration. Waiting the full parent duration turns one flowing gesture into two separate ones, and an empty panel that sits there reads as broken or still loading
  • Reverse it on exit. The child leaves first, the parent follows about 75ms later, and both exit durations are roughly half the entry durations. Dropdowns and popovers should get out of the way faster than they arrive
  • Total staged time stays under about 500ms. Delay plus duration is the number that matters, and 125 + 250 already spends 375ms of that budget. Staging is not free, so do not stack a third tier onto it without checking the total

Put the exit settings on the base selector and the entry settings on the open state, so the browser picks the right pair automatically.

.dropdown {
  opacity: 0;
  transition: opacity 125ms;
  transition-delay: 75ms;
}
.dropdown .content {
  opacity: 0;
  transition: opacity 125ms;
}

.dropdown[data-open] {
  opacity: 1;
  transition: opacity 250ms;
  transition-delay: 0ms;
}
.dropdown[data-open] .content {
  opacity: 1;
  transition: opacity 250ms;
  transition-delay: 125ms;
}
  • The transition-delay: 0ms on the parent's open rule is load-bearing. Without it the parent inherits the 75ms exit delay and the whole entrance starts late

When the element mounts and unmounts

  • Component libraries that create and destroy the DOM node give a transition nothing to interpolate from. Use keyframe animations instead, which run the moment the element appears
@keyframes fade {
  from { opacity: 0; }
}

.dropdown {
  animation: fade 250ms forwards;
}
.dropdown .content {
  animation: fade 250ms both;
  animation-delay: 125ms;
}
  • both is the entire trick on the child. A fill mode of backwards or both displays the animation's first frame during the delay countdown, so the child holds at opacity: 0 instead of flashing at full opacity for 125ms and then fading in from nothing
  • A partial keyframe with only a from block takes its end state from the element's own computed style, so the animation does not need to restate it
  • Exit animations do not survive unmounting. Either keep the node alive until the exit finishes, or use view transitions, which own that problem. See view-transitions

Exits driven by the action, not the state

  • A dialog is not simply open or closed. Two distinct actions reach the closed state, and treating them as one state throws away information the user wants
  • Give each action its own exit. Cancel evaporates: blur, drop away, fade. Confirm leaves with an anticipation curve, pulling back slightly before it commits
  • Keep the gap between them small. interactions in the ux docs puts the ceiling well: the difference should read as a direction rather than a different effect, so the user feels the outcome instead of noticing the animation. The blur and rotation below are the loudest this should ever get, and a plain difference in direction is usually enough
  • The practical payoff is feedback. When a click lands on the boundary between two buttons, or the user was not watching closely, the exit tells them which one they hit
  • Implement it by writing the action onto the element as a data attribute and selecting on that attribute in CSS, exactly as you would select on :hover
.dialog {
  transform: translateY(100%);
}
.dialog[data-action="open"] {
  transform: translateY(0);
  transition: transform 300ms;
}
.dialog[data-action="cancel"] {
  transform: translateY(100%) rotate(-20deg);
  opacity: 0;
  filter: blur(10px);
  transition:
    transform 500ms,
    opacity 500ms,
    filter 500ms;
}
.dialog[data-action="confirm"] {
  transform: translateY(100%);
  transition: transform 600ms cubic-bezier(0.54, -0.8, 1, 0.9);
}
  • The negative second control point is what produces the anticipation. The dialog rises a little before it falls, which reads as a decision rather than a dismissal
  • Use a real dialog primitive underneath. This pattern only supplies the CSS; focus trapping, escape handling and the accessible role are not optional and are not shown here

The attribute persists, and that shows

  • data-action sticks around until the next action replaces it. Reopen after a cancel and the dialog animates from rotate(-20deg) to level, so it visibly un-rotates on the way up. The reported symptom is "my dialog opens crooked and straightens itself"
  • The tempting fix is to move the rotation onto the standalone rotate property and only transition it on cancel, so it snaps back to zero on open. Two costs come with it. Individual transform properties apply in a fixed order, translate then rotate then scale, so you lose control over composition: translateY(100%) rotate(-20deg) falls straight down while rotate(-20deg) translateY(100%) arcs sideways. And an interrupted cancel snaps the rotation instead of easing it, which is worse than the bug being fixed
  • The fix that holds is to remove the attribute once the exit has finished. Schedule a timeout slightly longer than the transition, and have it check that the attribute still holds the action it was scheduled for before removing anything
cancelBtn.addEventListener("click", () => {
  dialog.setAttribute("data-action", "cancel");

  window.setTimeout(() => {
    if (dialog.getAttribute("data-action") === "cancel") {
      dialog.removeAttribute("data-action");
    }
  }, 600); // transition is 500ms; the extra 100ms is headroom
});
  • Each timeout validates its own precondition, so an interrupted cancel is handled by the callback finding open and doing nothing. That is why this beats storing timeout ids and clearing them from three different handlers: there is no shared bookkeeping to get out of sync
  • Removing the attribute returns the element to the same styles it had on page load, which is the state the next open animation should start from

Stagger curves

  • Most staggers should not be long. interactions in the ux docs caps the default case at roughly 5 pieces with 40ms to 80ms between them, and a fixed offset is fine at that length. This section is for the exception: a decorative sequence long enough that the spacing itself becomes visible
  • A fixed index * 25ms is the obvious stagger and the one that reads mechanical. Over a long sequence the eye picks up the constant spacing and the whole thing feels like a metronome
  • Distribute the delays on an exponential curve instead. Early items land almost together and later ones spread out, so the sequence rushes in and decelerates
function staggerDelay(index, count, window, exponent = 2) {
  const progress = count > 1 ? index / (count - 1) : 0;
  return progress ** exponent * window;
}
  • The denominator is count - 1, not count, because a zero-based index only reaches count - 1. Divide by count and the last item lands short of the window every time, and the shortfall grows as the list gets smaller: over four items the last one arrives at 56% of the intended window, so the sequence stops before it looks finished
  • The count > 1 guard covers the single-item case, where count - 1 would be a division by zero. One item has nothing to stagger against, so it starts immediately
  • Scale the window to the data, not to a fixed element count. A progress ring filling 100 segments should not take the same total time at 12% as at 94%, or the low value crawls and the high value blurs past
// score is the number of segments being rendered, and segmentIndex runs 0 to score - 1
// window grows with the value, so short fills stay snappy and long fills stay readable
const delay = staggerDelay(segmentIndex, score, score * 30);
  • The last segment lands exactly on score * 30, so a 100-segment fill spreads across 3000ms and a 12-segment fill across 360ms. A score of 1 falls through the guard and paints with no delay
  • The same idea applies anywhere the sequence length is data-driven: a list of results, a set of bars, a ring. Tie the window to the count you are actually rendering

Stagger by varying the spring, not the delay

  • Delay-based stagger has one specific failure: re-trigger it mid-flight and items still inside their delay never started, while items already moving get retargeted from wherever they happened to be. The result is elements frozen at odd suspended positions, which is what "the list gets stuck halfway" usually means
  • Give each item a slightly different spring instead. The cascade comes from items reaching their targets at different times, and nothing is waiting on a clock, so an interruption just retargets springs that were already in motion. Whether the existing velocity carries into the new animation is what makes the interruption invisible, and it varies by library: some do it by default and others expect you to pass the current velocity in. motion-hooks has the per-library detail
function itemSpring(index, count) {
  const t = count > 1 ? index / (count - 1) : 0;
  return {
    type: "spring",
    stiffness: 260 - t * 40,
    damping: 26 + t * 4,
  };
}
  • Later items get lower stiffness and higher damping, so they move with more lethargy. That reads as weight distributed across the group rather than a queue
  • Vary the values only slightly. A narrow band around a base reads organic; a wide spread reads broken, because the last item is still travelling long after the first has settled
  • This is also the fix for items bunching up at the start of a shared-layout group move. Identical springs move every item in perfect lockstep, so the group reads as one solid block sliding rather than several objects relocating

Sequences with an irregular cadence

  • setInterval locks every step to the same gap, which is what makes a typed-out line read as a teletype rather than a person
  • A self-rescheduling setTimeout lets each iteration choose its own delay, so the cadence can vary within a band. A wide band, roughly 50ms to 350ms between characters, is what sells it as typing
function typeInto(node, text, { min = 50, max = 350 } = {}) {
  const remaining = [...text];
  let timeoutId;

  function step() {
    if (!remaining.length) return;
    node.textContent += remaining.shift();
    timeoutId = setTimeout(step, min + Math.random() * (max - min));
  }

  step();
  return () => clearTimeout(timeoutId);
}
  • Interruption needs two things, and missing either one is the usual bug. Clear the pending timeout, and reset the source state. Clearing the handle alone leaves the previous run's remaining characters in play, so a second trigger runs two interleaved loops writing into the same node
  • Consuming the source by shifting items off a copy makes the terminating condition a falsy check on an empty array rather than an index compared against a length, which removes the off-by-one entirely
  • Reduced motion means the text arrives whole and immediately. Never gate the content itself on the sequence finishing, and never leave a partially typed string as the reduced-motion end state

Squash and stretch in plain CSS

The library version of this effect derives deformation from velocity. The CSS version is two keyframe animations and is enough for most cases.

@keyframes bounce {
  0%   { translate: 0; }
  5%   { translate: 0; }
  100% { translate: 0 -100px; }
}

@keyframes squash {
  0%   { scale: var(--squash) calc(1 / var(--squash)); }
  10%  { scale: calc(1 / var(--stretch)) var(--stretch); }
  100% { scale: 1; }
}

.ball {
  --squash: 1.4;
  --stretch: 1.25;
  transform-origin: center bottom;
  animation:
    squash 800ms linear infinite,
    bounce 400ms cubic-bezier(0.123, 0.5, 0.5, 1) infinite alternate;
}
  • Two animations need two properties. translate and scale are separate individual properties precisely so each can be owned by its own animation; a single transform cannot be split across two
  • transform-origin: center bottom is what keeps the squash glued to the ground. With the default centre origin the element flattens around its middle and appears to sink into the floor
  • The duplicated 0% and 5% frames in bounce are a dead hold. Without it the splat happens while the element is already climbing, which reads as deforming in mid-air. Widen the hold until the squash frame finishes before the element leaves the ground
  • bounce runs at half the squash duration with alternate, which reverses direction on each even cycle. That gives an up and a down inside one squash cycle, so the deformation lands once per contact
  • calc(1 / var(--squash)) keeps the area roughly constant, which is what makes the deformation read as a soft object rather than a resized one
  • Tasteful deformation is roughly 25% to 50%, so a squash ratio between 1.25 and 1.5. More than that is cartoon, which is a valid choice but a loud one
  • Tune the curve by eye. The physically accurate parabola is available and looks worse than a hand-tuned curve; gravity simulated with a Bézier is unconvincing at slow speeds no matter which values you pick
  • Most real uses of squash and stretch involve no gravity at all, which is why the arrow below matters more than the ball

Stretchy arrows

The most reusable application: an arrow that lengthens on hover and gets thinner as it does.

<svg class="arrow" viewBox="0 0 24 24" aria-hidden="true">
  <path class="shaft" d="M 5,12 h 14" />
  <path class="tip" d="M 12,5 l 7,7 l -7,7" />
</svg>
.shaft,
.tip {
  transition: d var(--spring-glide-duration) var(--spring-glide);
}

.btn:is(:hover, :focus-visible) .shaft {
  d: path("M 5,12 h 17");
}
.btn:is(:hover, :focus-visible) .tip {
  d: path("M 15,7 l 7,5 l -7,5");
}
  • Length alone reads flat. The shaft grows from 14 to 17 units while the tip's half-height drops from 7 to 5, so the chevron narrows as the arrow extends. That is the squash and stretch rule applied to a 24 by 24 icon
  • The tip also starts 3 units further right, so it stays attached to the end of the longer shaft instead of floating off it
  • Both path() values keep the same commands in the same order. d is only interpolable between paths with matching data points; change the command count and the shape jumps
  • Do not reach for @supports here. Safari parses the d property and then does nothing with it, so the feature query passes in the one engine you needed to exclude. svg-animation has the compatibility detail
  • Either animate d through a JavaScript animation library, which does the interpolation itself, or pick an effect that does not need it, such as translating the whole SVG a few pixels
  • Pair it with a boop so the stretch completes on a drive-by hover instead of sticking while the pointer sits there. The enter-then-clear structure and its timing live in hover-and-boop-patterns

Judge icon motion at shipping size

  • A squash that looks violent at eight times magnification is roughly correct at a real 16 by 16 render. Small icons need exaggerated deformation because there are too few pixels for a subtle one to register
  • The failure runs both ways: tuned in a zoomed playground, an icon animation ships invisible; tuned at 16px and then used at 48px, it looks like a cartoon
  • Always check the motion in the actual component at the actual size before signing it off

First visit and every visit after

  • The standard advice, never make users wait for an animation, pushes toward deleting the flourish. That is the wrong conclusion. A four-second introduction is charming once and infuriating on the fortieth load
  • Gate it instead. Play the full flourish on a first visit, and make it instant afterwards. Nobody loses the moment, and nobody sits through it twice
  • If your product has accounts, store the flag on the user record. It then follows the person across devices and survives a cleared browser
const KEY = "intro-seen";

// Reads and writes the flag in one go, so the next visit is already recorded
function takeFirstVisit() {
  try {
    const seen = window.localStorage.getItem(KEY) !== null;
    window.localStorage.setItem(KEY, "1");
    return !seen;
  } catch {
    return true; // storage blocked or full, so treat every visit as the first
  }
}

const decided = useRef(false);

// Runs after mount, which is the earliest point where window exists
useEffect(() => {
  if (decided.current) return;
  decided.current = true;

  if (takeFirstVisit()) {
    playIntro();
  } else {
    jumpToEndState();
  }
}, []);
  • The ref guard is not ceremony, it is what makes the effect survive development. Strict Mode deliberately runs effect setup twice, and this effect consumes the flag, so the second run reads the value the first run just wrote and takes the returning-visitor path. The intro you are trying to build never plays. When storage is blocked the fallback fails the other way and plays it twice. Either way you spend an afternoon convinced your storage is broken
  • window does not exist during server rendering, so reading storage at module scope throws before the page ever reaches a browser. Keep the access inside an effect, an event handler or anything else that only runs on the client
  • Wrap it in try regardless. Storage access throws outright when a browser blocks it or the quota is full, and an unguarded read takes the whole component down with it rather than costing you one flourish
  • Local storage works, with honest caveats. It is per device, so one person on a laptop, a work machine and a phone sees the first-time experience three times. Users can disable it, in which case they always get the long version. Browsers can purge it under storage pressure. And storing visitor state may carry privacy-compliance obligations depending on where you operate, so check before shipping it
  • Whichever store you use, the gate may only change how long the arrival takes. Returning visitors must land on the same end state with the same content

One concept, many executions

  • Pick one or two interaction concepts and derive every hover in the product from them. Interactive things brighten. Arrows stretch toward where they lead. Surfaces lift on press. One idea, applied everywhere
  • Vary the execution freely: a stretching arrow in one place, a trail of fading arrows in another, an arrow that fades in beside a label in a third. Same concept, different expression
  • Share the concept, not necessarily one component. A single <HoverableButton> used everywhere goes stale fast, and the constraint that matters is the idea, not the implementation
  • The failure mode is a page of individually impressive borrowed effects. Each one is well built and none of them belong together, so the page reads as generic and visually disconnected, because each effect's design language appears nowhere else on the page
  • Let the visual design pick the concept. A monochrome palette rules out shifting hue on hover and leaves lightness, so the motion concept is already half decided before any code is written

Reduced motion

  • Orchestration is pure movement, so all of it has a reduced path. Collapse a staged sequence to a single instant state change or one plain fade with no delays. The content still arrives, it just arrives together
  • Gate the staging in rather than unpicking it afterwards. Keep the states in the ordinary rules and put every transition, duration and delay inside @media (prefers-reduced-motion: no-preference). The feature has exactly two values, so a browser that cannot evaluate the query matches neither and gets the states with no motion between them. accessibility.md has the argument in full
.dropdown { opacity: 0; }
.dropdown .content { opacity: 0; }
.dropdown[data-open] { opacity: 1; }
.dropdown[data-open] .content { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  .dropdown {
    transition: opacity 125ms;
    transition-delay: 75ms;
  }
  .dropdown .content {
    transition: opacity 125ms;
  }
  .dropdown[data-open] {
    transition: opacity 250ms;
    transition-delay: 0ms;
  }
  .dropdown[data-open] .content {
    transition: opacity 250ms;
    transition-delay: 125ms;
  }
}
  • The ungated rules are the whole state machine: closed is transparent, open is opaque, and every timing value sits in the gate. A reduced-motion user gets the open panel and its content immediately
  • The action-driven dialog splits the same way, and the split is where the care goes. The translateY(100%) on cancel is the closed state, not the flight, so it stays ungated; the rotation, the blur and the durations go in the gate. Strip the closed transform along with the motion and you leave a dismissed dialog sitting on screen, which is the classic way a reduced-motion rule breaks a feature
.dialog { transform: translateY(100%); }
.dialog[data-action="open"] { transform: translateY(0); }
.dialog[data-action="cancel"] {
  transform: translateY(100%);
  opacity: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .dialog[data-action="open"] {
    transition: transform 300ms;
  }
  .dialog[data-action="cancel"] {
    transform: translateY(100%) rotate(-20deg);
    filter: blur(10px);
    transition:
      transform 500ms,
      opacity 500ms,
      filter 500ms;
  }
}
  • Action-driven exits carry meaning, so removing the motion removes information. Replace it with the resulting state rather than the flight: a status message that says which action ran, a toast, or the changed row itself. Put it in a live region so it reaches screen readers, who never had the animation in the first place
  • A stagger has no reduced version. Its delays live in the gate with every other timing value, so every item appears at once. Do not keep a shortened stagger; the whole point of the preference is that sequential movement is what triggers discomfort
  • Do not run squash, stretch or bounce loops at all. There is no meaning in them to preserve
  • The reduced-motion preference outranks the first-visit flag. A first-time visitor who asked for reduced motion gets the instant path, and the flag is still recorded so nothing is replayed later

Common mistakes

  • Fading a panel and its contents together, which leaves several frames of half-transparent text stacked on half-transparent text
  • Delaying the child by the parent's full duration, which splits one gesture into two and makes the empty panel look like it is still loading
  • Staging a sequence past about 500ms total, so the user is now waiting rather than watching
  • Using forwards instead of both on a delayed child, which shows it at full opacity for the length of the delay and then fades it in from nothing
  • Treating a dialog as open or closed when two different actions close it, throwing away the one signal that tells the user which button they hit
  • Leaving data-action on the element after the exit, so the next open animates out of the cancelled state and visibly un-rotates
  • Clearing the reset timeout from every handler instead of having the callback verify the attribute still matches the action it was scheduled for
  • A fixed index * 25ms delay across a long sequence, which reads as a metronome
  • Scaling the stagger window to a fixed element count rather than to the value being displayed, so low values crawl and high values blur past
  • Delay-based stagger on anything re-triggerable, which strands items at suspended positions when it is interrupted mid-flight
  • Spreading spring parameters too widely across a group, so the last item is still travelling long after the first has settled
  • Squashing with the default centre transform-origin, so the element sinks through the floor instead of splatting on it
  • Deforming in mid-air because the bounce keyframe has no dead hold at the start
  • Stretching an arrow without thinning it, which reads flat no matter how good the easing is
  • Tuning icon deformation at 8x magnification and shipping something invisible at 16 by 16
  • Deleting a first-visit flourish outright instead of gating it, or gating it in a way that changes what returning visitors can reach
  • A page of borrowed effects with no shared concept, which reads as generic however well each one is built
  • Stripping the transform from a reduced-motion exit, which leaves the dismissed element on screen

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "motion-orchestration" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems