Skip to content

Looping and Multi-Stage Motion

A UI principle for coding agents. Also covers keyframes, keyframe timing, times array, repeat, loop animation, ambient motion, and 8 more.

Show all 14 aliases

keyframes, keyframe timing, times array, repeat, loop animation, ambient motion, idle animation, pulse, breathing, multi-stage animation, repeatDelay, animation-delay, pause between loops, animation delay only works once

Motion with more than two states, or motion that repeats, needs control over when each stage lands and what happens between cycles. Left on defaults, both read as mechanical: evenly spaced stages and an unbroken loop are the two most common reasons ambient motion feels cheap.

Place Keyframes Where They Belong

An array of values animates through each one in turn. By default they are spread evenly across the duration, which is rarely what the movement wants.

times takes a value from 0 to 1 for each keyframe, saying when it lands as a fraction of the total.

<motion.div
  animate={{ rotate: [0, 25, 0] }}
  transition={{ duration: 1, times: [0, 0.4, 1] }}
/>

Here the rotation reaches 25 degrees at 400ms, then takes the remaining 600ms to return. The slower return reads as a settle rather than a snap back.

  • The array must hold one value per keyframe, or the animation will not run as written
  • Front-load the values to make a movement feel eager, back-load them to make it feel weighty
  • A pair like [0, 0.4] on a two-stage animation gives a fast move and a long hold

Reach for this whenever a movement has a moment that matters. The impact of a bounce, the peak of a wave, the point where a hand lands. Even spacing puts that moment in the wrong place.

Choose How a Loop Turns Over

A repeating animation has to get back to its starting state somehow, and the choice is visible.

<motion.div
  animate={{ y: [0, -8] }}
  transition={{ repeat: Infinity, repeatType: "reverse", duration: 1.2 }}
/>
  • loop restarts from the beginning, which shows a jump unless the end state matches the start
  • reverse plays backwards, giving a smooth there-and-back for breathing, floating or pulsing
  • mirror swaps the origin and target each cycle

Default to reverse for ambient motion. Use loop only when the movement genuinely returns to where it began, such as a full rotation.

Give Loops Somewhere To Rest

Continuous motion competes with everything else on the page. The same animation with a pause between cycles reads as alive rather than restless.

transition={{
  repeat: Infinity,
  repeatType: "loop",
  repeatDelay: 2,
}}
  • A pause of 2 to 4 seconds suits an idle hint or a nudge toward an action
  • Without one, a looping animation pulls the eye away from whatever the user is actually doing
  • If the loop is decorative rather than informative, consider whether it should run at all

CSS Has No repeatDelay

animation-delay waits once, before the animation begins to play. It is spent at that point and never applies again, so it cannot space out the iterations of an infinite animation. Setting it and expecting a gap between cycles is a common mistake, and the symptom is "my delay only works the first time".

Bake the rest into the keyframe percentages instead. Extend the duration to cover motion plus pause, then confine the movement to the fraction of the cycle it should occupy.

.nudge {
  animation: nudge 4000ms ease-in-out infinite;
}

@keyframes nudge {
  0%, 70% { translate: 0; }
  80% { translate: 4px 0; }
  90%, 100% { translate: 0; }
}

The movement runs from 70% to 90% of a 4 second cycle, so it takes 800ms and the remaining 3.2 seconds are a hold at the resting position. Change the pause by changing the duration and shifting the percentages, not by adding a delay.

css-motion-mechanics.md owns keyframe percentages and the rest of the CSS animation surface in depth, and reveal-techniques.md owns fill modes. This is only the constraint you need to know before reaching for animation-delay.

Hold the Final Frame

A CSS keyframe animation returns to its starting state when it ends unless animation-fill-mode says otherwise. The forwards value keeps the last frame, which is what any entrance or reveal wants.

.enter {
  animation: fade-up 600ms cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
}

Pair it with animation-iteration-count only when the motion is meant to repeat. A reveal that loops is a bug.

Stop Every Loop Under Reduced Motion

Ambient loops are the clearest case the reduced-motion preference exists for: unending movement in the corner of the eye with no way to dismiss it. Never let one start unless motion is welcome.

Declare the loop inside @media (prefers-reduced-motion: no-preference) rather than switching it off inside a reduce block. The feature has exactly two values, so a browser that cannot evaluate the query matches neither, and this direction leaves that browser still. accessibility.md has the argument in full.

.nudge,
.pulse,
.float {
  translate: 0;
  scale: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .nudge {
    animation: nudge 4000ms ease-in-out infinite;
  }
}
  • A loop that never starts must leave the element visible and at rest. The ungated rule is what a motion-free browser paints, so it has to hold the resting state, not the hidden or off-position frame the loop started from
  • A loop that was carrying information, such as a pulse marking an unread item, needs a static equivalent when the motion is gone. Give it a persistent dot, a badge or a label rather than nothing
  • In JavaScript, start the loop only on a positive match of window.matchMedia("(prefers-reduced-motion: no-preference)"), rather than starting it and stopping it a frame later. Testing the positive query means a browser that cannot evaluate it never starts the loop at all

Checklist

  • Multi-stage animations set times rather than accepting even spacing
  • The times array holds one value per keyframe
  • Ambient loops use repeatType: "reverse" unless the motion truly returns to its start
  • Looping motion has a repeatDelay so it is not continuous
  • In CSS the pause between cycles lives in the keyframe percentages, never in animation-delay
  • Entrances and reveals set forwards so the end state holds
  • Every loop is declared inside @media (prefers-reduced-motion: no-preference), and the ungated rule leaves the element in a resting state that is still visible

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: "looping-motion" })
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