MynduiMyndui
0

Anatomy of Marquee

How duplicated tracks, a CSS translate loop, and an edge mask make an infinite scroll that never paints twice.

01/04Duplicated tracks, not a longer row

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
tsx
{Array.from({ length: Math.max(2, repeat) }, (_, i) => (<div key={i} aria-hidden={i > 0} className={trackClass}>  {children}</div>))}
01

Duplicated tracks, not a longer row

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.

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.

02/04Pure CSS motion

animate-marquee · --duration

[animation-direction:reverse]

group-hover:[animation-play-state:paused]
Duration
--duration from speed prop
Reverse
animation-direction: reverse
Pause
group-hover play-state paused
tsx
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(" ");
02

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.

03/04Edge fade via mask, not an overlay

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
tsx
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)] …": "";
03

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.

04/04Result
04

The result

Duplicate tracks, one CSS translate loop, a mask for soft edges — and nothing on the main thread after mount.

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.

Motion Score

MarqueeSSCompositor-only
StransformCompositor transform (translate / scale / rotate)
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →