Seamless Loops with animation-direction: alternate
Part of Keyframe Architecture & State Mapping in Core CSS Animation Fundamentals.
The problem
A floating illustration bobs up and down, and at the end of every cycle it snaps back to the bottom. A logo marquee scrolls smoothly and then jumps backwards every twenty seconds. A “breathing” glow pulses, but the easing feels lopsided — slow on the way out and abrupt on the way back. A row of dots meant to ripple in sequence all pulse in unison.
Every one of these is a loop whose end state does not match its start state, whose easing does not match in both directions, or whose phases were never offset.
Root cause analysis: iterations restart at the first keyframe
With the default animation-direction: normal, each iteration runs from 0% to 100% and the next begins at 0% again. If the 100% keyframe is not identical to 0%, the jump between them is visible on every cycle.
alternate removes the restart for back-and-forth motion. Odd iterations run 0% to 100%; even iterations run 100% back to 0%. There is no discontinuity because each iteration begins where the previous one ended. alternate-reverse does the same starting from 100%.
Easing is mirrored. On reversed iterations the timing function runs backwards too: an ease-in on the way out becomes an ease-out shape on the way back, measured in time. For a bob, ease-in-out is symmetric and looks identical both ways. An asymmetric curve like ease-out makes one half fast-then-slow and the other slow-then-fast in value, which reads as lopsided.
One-direction loops need matching ends. A spinner or a marquee cannot alternate — it must keep going the same way. The fix is geometric: a rotation from 0turn to 1turn ends in the same orientation it started. A marquee translates a strip containing the content twice, by exactly half its width, so the frame at 100% looks identical to the frame at 0%.
Phase offsets use negative delays. animation-delay: -0.4s starts an animation as if it had already been running for 0.4s. Unlike a positive delay, the element is animating from the first frame, just at a different point in the cycle — which is what a ripple of dots needs.
Step-by-step resolution
Production code pattern
/* Back-and-forth: alternate with a symmetric curve. */
.float {
animation: bob 2.8s ease-in-out infinite alternate;
}
@keyframes bob {
from { translate: 0 0; }
to { translate: 0 -8px; }
}
/* Ripple of dots: same animation, phases offset with negative delays. */
.dots span {
animation: pulse 1.2s ease-in-out infinite alternate;
animation-delay: calc(var(--i) * -0.2s); /* already mid-cycle on the first frame */
}
@keyframes pulse {
from { opacity: 0.3; scale: 0.85; }
to { opacity: 1; scale: 1; }
}
/* One-way marquee: content duplicated, strip moves exactly half its width. */
.marquee__track {
display: flex;
inline-size: max-content;
animation: marquee 30s linear infinite;
}
@keyframes marquee {
to { translate: -50% 0; } /* frame at 100% matches frame at 0% */
}
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee[data-paused] .marquee__track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.float, .dots span { animation: none; }
.marquee__track { animation: none; flex-wrap: wrap; inline-size: auto; } /* static, all logos visible */
}
<div class="marquee" aria-label="Customer logos">
<ul class="marquee__track">
<li>…logos…</li>
<li aria-hidden="true">…the same logos again…</li> <!-- duplicate hidden from AT -->
</ul>
</div>
Rendering Impact: composite. All three loops animate
translate,opacityandscale. Infinite loops keep their layers alive for as long as they run, so pause them when off-screen as well as on user request.
The marquee duplicate carries aria-hidden="true" so screen readers read the logos once. In the reduced-motion branch the duplicate is still in the DOM; hide it visually there too if the doubled logos look odd when wrapped.
Verification checklist
Constraints and trade-offs
alternatedoubles the perceived period: a 2s animation takes 4s to return to its start.- Asymmetric easing under
alternateproduces different-feeling halves by design. - A marquee’s duration must scale with content width or the speed changes when logos are added.
- Negative delays cannot exceed the loop in a meaningful way; delays beyond one period wrap.
- Motion that loops for more than five seconds and starts automatically needs a pause mechanism to meet WCAG 2.2.2.
Frequently asked questions
Why does my looping animation jump at the end?
Each iteration restarts at the first keyframe. Use animation-direction: alternate for back-and-forth motion, or make the last keyframe visually identical to the first.
Does alternate reverse the easing too?
Yes. The reversed iteration runs the timing function backwards, so symmetric curves like ease-in-out look the same in both directions and asymmetric ones do not.
How do I make a seamless CSS marquee?
Duplicate the content inside the moving strip and translate the strip by exactly -50% with linear timing, so the final frame matches the first.
How do I stagger infinite animations?
Use negative animation-delay values. They start each element mid-cycle immediately, whereas positive delays leave elements static until their delay ends.
Related
- Keyframe Architecture & State Mapping — the parent topic
- Providing Pause Controls for Autoplay Motion — required for long loops
- Pausing Off-Screen and Background-Tab Animations — stopping loops nobody sees