Anatomy of Marquee
How duplicated tracks, a CSS translate loop, and an edge mask make an infinite scroll that never paints twice.
↓Scroll to step through it
Duplicated tracks, not a longer row
Marquee clones its children into enough identical tracks to tile the
overflow box, then lets CSS translateX / translateY carry them. It never
measures content or runs a JS ticker: one duration token sets the speed,
animation-direction reverses it, and animation-play-state pauses it.
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.
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.
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 edge pixels
are not drawn.
The result
Duplicate tracks, one CSS translate loop, and a mask for soft edges. Nothing runs on the main thread after mount.
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>))}Anatomy of Marquee
How duplicated tracks, a CSS translate loop, and an edge mask make an infinite scroll that never paints twice.
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>))}Duplicated tracks, not a longer row
Marquee clones its children into enough identical tracks to tile the
overflow box, then lets CSS translateX / translateY carry them. It never
measures content or runs a JS ticker: one duration token sets the speed,
animation-direction reverses it, and animation-play-state pauses it.
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.
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(" ");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.
The keyframes themselves are compositor-only (translateX(0) to
translateX(calc(-100% - var(--gap)))), so the browser never lays out
between frames.
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)] …": "";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 edge pixels
are not drawn.
The result
Duplicate tracks, one CSS translate loop, and a mask for soft edges. Nothing runs on the main thread after mount.
Reduced motion
motion-reduce:animate-none on every track freezes the loop. The duplicated
content stays, so readers see a static strip instead of a scrolling one, and
aria-hidden on the clones still applies.
Motion Score
transformCompositor transform (translate / scale / rotate)opacityFade / cross-fade