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 }}
/>
looprestarts from the beginning, which shows a jump unless the end state matches the startreverseplays backwards, giving a smooth there-and-back for breathing, floating or pulsingmirrorswaps 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
timesrather than accepting even spacing - The
timesarray holds one value per keyframe - Ambient loops use
repeatType: "reverse"unless the motion truly returns to its start - Looping motion has a
repeatDelayso it is not continuous - In CSS the pause between cycles lives in the keyframe percentages, never in
animation-delay - Entrances and reveals set
forwardsso 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