9 CSS Marquee Infinite Loop — No JS, Scroll-Driven
A css marquee infinite loop is a row of duplicated content — logos, a price ticker, a headline — that slides on repeat using only a CSS transform and a
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Seamless loop formula
- 02 Infinite logo marquee
- 03 Pause on hover
- 04 Fade edges via mask
- 05 Vertical ticker
- 06 Dual direction rows
- 07 Variable-speed flow
- 08 3D curved marquee
- 09 Static fallback for reduced motion
The nine below build in the order the technique actually grows. 01 is the bare formula every other item reuses. 02 turns it into the layout most sites actually ship. 03 and 04 add an interaction and a visual finish. 05 and 06 bend the same formula onto a new axis and a second track. 07 and 08 are the idea-form entries — a non-linear rhythm and a curved, three-dimensional wall — and 09 closes on the one pattern built entirely around what a browser setting removes rather than what CSS adds. Every loop below runs on a two-second cycle so the grid keeps moving inside a fixed, non-scrollable iframe. A real banner usually takes ten to twenty seconds per lap, which reads calmer than what's playing above.
01Seamless loop formula
Six chips, lettered A through F, sit in a flex track duplicated into exactly two copies, and the track only ever moves from translateX(0) to translateX(-50%). Each chip carries its own margin-right for the space between them, so the first six chips add up to exactly half the track's total width and the clone lands on the original's start position without a seam. Every pattern below borrows this same skeleton.
.marquee__track {
display: flex;
width: max-content;
animation: marquee-scroll 12s linear infinite;
}
.marquee__chip {
margin-right: 14px;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
02Infinite logo marquee
Five wordmarks — NORTH, ACME, ORBIT, VELA, and KADE — sit inside pill-shaped cards under a small "Partners" label and stream through the same seamless track as item 01. Nothing about the pattern changes; only the chip markup adds a shadow and a rounded background, so the row reads as a client strip instead of a raw alphabet test.
.marquee__track {
display: flex;
animation: logo-scroll 12s linear infinite;
}
.marquee__logo {
margin-right: 16px;
height: 34px;
padding: 0 16px;
border-radius: 999px;
background: #fff;
box-shadow: $shadow-press;
}
03Pause on hover
A single line — "Join today, first month free" — repeats once, and animation-play-state flips to paused the instant a pointer lands on :hover or a keyboard focuses the banner via :focus-within. That holds whatever word the reader was on. Nothing about the track's position resets when it pauses, so hovering over the banner again a second later resumes from that same frame instead of restarting.
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee:active .marquee__track {
animation-play-state: paused;
}
.marquee__track {
display: flex;
animation: marquee-scroll 10s linear infinite;
}
.marquee__chip {
margin-right: 40px;
}
04Fade edges via mask
Chips numbered 01 through 05 stream through a track wrapped in a container carrying mask-image. That fades the two edges of the strip into whatever sits behind them instead of clipping them with a hard overflow: hidden line. Unprefixed mask-image reached Chrome 120, Safari 15.4, and Firefox 53 — caniuse tracks the exact versions — so the -webkit- duplicate in the demo is mostly a habit at this point, not a real requirement for a modern target list.
.marquee {
overflow: hidden;
mask-image: linear-gradient(to right,
transparent, #000 14%, #000 86%, transparent);
-webkit-mask-image: linear-gradient(to right,
transparent, #000 14%, #000 86%, transparent);
}
05Vertical ticker
Four rows — AAPL, GOOG, MSFT, NVDA, each with a price and an up or down arrow — sit in a fixed-height box and scroll upward instead of sideways. It uses translateY in place of translateX on the exact same two-copy, -50% formula from item 01. Swapping the axis is the entire change; the duplication math underneath never moves.
.ticker__track {
display: flex;
flex-direction: column;
animation: ticker-scroll 12s linear infinite;
}
@keyframes ticker-scroll {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
06Dual direction rows
Two rows — DESIGN, MOTION, LAYOUT running forward and CSS, SCSS, REACT running backward — share one marquee-scroll animation, and only the second row adds animation-direction: reverse. No second @keyframes block exists anywhere in the file; direction is the only property that differs between the two tracks.
.marquee__track {
animation-name: marquee-scroll;
animation-duration: 12s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.marquee__row--rev .marquee__track {
animation-direction: reverse;
}
07Variable-speed flow
The same -50% distance from item 01 runs under cubic-bezier(.65, 0, .35, 1) instead of linear, so the loop slows down right at the seam and speeds up through the middle of the lap. Nothing about the duplication or the travel distance changes — only the rhythm a reader feels while the row is in motion.
.marquee__track {
animation: marquee-scroll 9s
cubic-bezier(.65, 0, .35, 1) infinite;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
083D curved marquee
A perspective on the stage and preserve-3d on the track let five blank cards each carry their own rotateY and translateZ step, bowing the strip into a shallow cylinder instead of a flat plane. A Sass @for loop repeats the same five-step angle table across the cloned half of the track. That way, the curve never flattens out or snaps at the seam the way a one-off, hand-written set of angles would.
.stage { perspective: 420px; }
.marquee__track {
display: flex;
transform-style: preserve-3d;
animation: marquee-scroll 12s linear infinite;
}
@for $i from 1 through 10 {
$step: ($i - 1) % 5;
.marquee__card:nth-child(#{$i}) {
transform: rotateY(#{($step - 2) * 22}deg);
}
}
09Static fallback for reduced motion
A toggle switches between the running track and the frozen, single-pass list that prefers-reduced-motion: reduce actually serves to a real visitor — News, Update, Event, no clones, no motion. A status label flips between "Playing" and "Paused" alongside it, so the difference is something a reader can see rather than take on faith.
@media (prefers-reduced-motion: reduce) {
.marquee__track {
animation: none !important;
transform: translateX(-30%);
}
}
Where it breaks — the traps
Two problems in this set don't show up just by watching the loop play — one needs a screen reader to catch, the other needs a ruler. Item 02's duplicated wordmark row renders NORTH, ACME, ORBIT, VELA, and KADE on screen exactly once each, but the DOM holds every name twice so the seam can line up. A screen reader that isn't told to skip the clone announces all ten, turning five partners into what sounds like a company twice its real size. MDN's aria-hidden reference explains why nothing looks wrong on screen while this is happening: the attribute only removes an element from the accessibility tree; it never hides anything visually, so a sighted check of the page misses the bug completely. Marking every span in the cloned half with aria-hidden="true", the same pattern all nine demos use, fixes it without moving a single pixel.
The seamless-loop math itself hides a second problem that only shows up once you measure the seam instead of eyeballing it. Twelve chips split into two sets of six only create eleven gaps between them, not twelve, so putting the spacing on the track's gap property leaves translateX(-50%) a half-gap short of the clone's real position. Every track above that spaced its clones with gap — everything except item 05's vertical rows, which already touch with no gap between them — jumped at that seam on every restart, by an amount that measured 4px to 20px depending on the item's own spacing value. caniuse's flexbox gap table confirms every browser this site targets supports gap on flex containers, so the fix isn't a compatibility workaround. It's margin-right on each individual chip instead of gap on the track: the spacing gets counted inside each set of six rather than between all twelve, so the first six chips land at exactly half the track's total width. Switching every affected demo above to that approach is exactly the fix bundled into the archive that unlocks with gcjydr57, typed in lowercase precisely as it reads in this sentence, digits and all.
Accessibility
All nine cancel their motion under prefers-reduced-motion: reduce, but they don't all land on the same kind of stillness. Items 01, 02, 04, 05, 06, 07, and 08 simply drop the animation and hold the track's last computed transform, so a reader sees a complete, unmoving row rather than a half-scrolled one. Item 03 needs no separate reduced-motion rule at all — the same :hover and :focus-within pause that helps a sighted mouse user also satisfies a vestibular one. Pausing on interaction already removes the motion a reduced-motion setting exists to prevent. Item 09 is the one built to demonstrate the setting rather than just obey it: flipping its toggle previews the exact frozen, single-pass list a real prefers-reduced-motion: reduce visitor gets, without needing to change an operating-system setting just to see it. MDN's page on the media feature covers which platform settings trigger it and how browsers read that preference.
Every other scroll-driven demo on this site sits behind the CSS category page, and what this project does and doesn't sell is laid out on the about page.
FAQ
Do I need JavaScript to build a css marquee infinite loop?
No — all nine tracks above move purely through a CSS @keyframes animation and a transform, with nothing reading scroll position or driving the loop from script. The zip in this article also ships a React version of each pattern for teams already working in components. The underlying animation in every one of them is still plain CSS, not a JS interval or a requestAnimationFrame loop.
Why does my marquee stutter or jump at the seam instead of looping smoothly?
The two most common causes are a duplicate count that isn't exactly double the original content, and a transform distance that isn't exactly -50% to match it. Either one leaves a visible gap or overlap once the loop restarts. A quieter version of the same stutter comes from gap on the flex container itself — the exact bug covered above. The count and the -50% distance can both be correct and the loop still jumps a few pixels at the seam, because gap only creates space between items: one fewer gap than the item count, not a trailing space after the last one in each set. Swapping gap on the track for margin-right on each item removes that jump.
Will a css marquee infinite loop pause automatically for users who prefer reduced motion?
Only if the CSS explicitly says so — nothing about animation: infinite respects a visitor's settings on its own. Each of the nine demos above carries its own @media (prefers-reduced-motion: reduce) block, or in item 03's case a pause trigger that already covers it. Leaving that block out means the track keeps scrolling regardless of what the reader's operating system requests.