Anatomy of Marquee
How duplicated tracks, a CSS translate loop, and an edge mask make an infinite scroll that never paints twice.
Marquee doesn't measure content or run a JS ticker. It clones its children
into enough identical tracks to tile the overflow box, then lets CSS
translateX / translateY carry them — one duration token, reverse via
animation-direction, pause via animation-play-state.
Duplicated tracks, not a longer row
Children are rendered Math.max(2, repeat) times. Each copy is its own
flex track with the same gap; track i > 0 is aria-hidden so assistive
tech only announces the first. When the lead track has scrolled exactly
one copy plus the gap, the next track has slid into the same visual slot
and the loop restarts without a seam.
Array.from({ length: Math.max(2, repeat) })
- Track 0
- live copy — readable to AT
- Track 1…n
- identical clones, aria-hidden
- repeat
- Math.max(2, repeat) tracks
{Array.from({ length: Math.max(2, repeat) }, (_, i) => (
<div key={i} aria-hidden={i > 0} className={trackClass}>
{children}
</div>
))}Pure CSS motion
Horizontal tracks get animate-marquee; vertical get
animate-marquee-vertical. Both read --duration from the speed prop
(seconds — higher is slower). right / down set
[animation-direction:reverse]. pauseOnHover adds
group-hover:[animation-play-state:paused] on the tracks inside the
group root. motion-reduce:animate-none kills the animation entirely.
animate-marquee · --duration
[animation-direction:reverse]
- Duration
- --duration from speed prop
- Reverse
- animation-direction: reverse
- Pause
- group-hover play-state paused
style={{ ["--duration" as string]: `${speed}s`, ...style }}
const trackClass = [
TRACK_BASE,
vertical ? "flex-col animate-marquee-vertical" : "flex-row animate-marquee",
reverse ? "[animation-direction:reverse]" : "",
pauseOnHover ? "group-hover:[animation-play-state:paused]" : "",
].filter(Boolean).join(" ");The keyframes themselves are compositor-only — translateX(0) to
translateX(calc(-100% - var(--gap))) — so the browser never lays out
between frames.
Edge fade via mask, not an overlay
When fade is on, the root gets a mask-image linear gradient:
transparent → black at 12% / 88% → transparent. Horizontal uses
to_right; vertical uses to_bottom. The same string is mirrored onto
-webkit-mask-image. Nothing sits on top of the content — the pixels at
the edges simply aren't drawn.
fade=false — hard clip at overflow
fade=true — soft edges via mask
- Mask
- linear-gradient transparent → black 12%/88%
- Horizontal
- to_right on both mask-image props
- Vertical
- to_bottom when direction is up/down
const fadeClass = fade
? vertical
? "[mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] …"
: "[mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)] …"
: "";Reduced motion
motion-reduce:animate-none on every track freezes the loop. The duplicated
content is still there — readers see a static strip instead of a scrolling
one — and aria-hidden on the clones still holds.
The result
Duplicate tracks, one CSS translate loop, a mask for soft edges — and nothing on the main thread after mount.