Infinite Marquee
Two rows of tags glide in opposite directions without a seam, and pause while you hover them.
- Branding
- Web design
- Motion
- Next.js
- Copywriting
- SEO
- E-commerce
- Dashboards
- Prototypes
- Landing pages
- Design systems
Build a seamless CSS marquee in React with two rows moving in opposite directions. In each row, render the item list twice inside one flex track (the second copy aria-hidden) and animate the track from translateX(0) to translateX(-50%) with a linear infinite animation (30s for row one, 36s reversed for row two), so the loop has no jump. Use gap on the track and padding-right equal to the gap so the halves line up exactly. Fade the edges with mask-image: linear-gradient(90deg, transparent, black 10%, black 90%, transparent). Pause the animation on hover and focus-within with animation-play-state: paused. Items are rounded tags. Under prefers-reduced-motion, stop the animation and let the row scroll horizontally instead.
Source
'use client'
import s from './demo.module.css'
function Row({ items, reverse }: { items: string[]; reverse?: boolean }) {
return (
<div className={s.row}>
<div className={reverse ? `${s.track} ${s.reverse}` : s.track}>
{[0, 1].map((copy) => (
<ul key={copy} className={s.list} aria-hidden={copy === 1 || undefined}>
{items.map((t) => (
<li key={t} className={s.tag}>
{t}
</li>
))}
</ul>
))}
</div>
</div>
)
}
export default function Marquee({ labels }: { labels: Record<string, string> }) {
return (
<div className={s.wrap}>
<Row items={labels.row1.split('|')} />
<Row items={labels.row2.split('|')} reverse />
</div>
)
}
.wrap { display: grid; gap: 12px; width: 100%; }
.row {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 10%, black 90%, transparent);
}
.track {
display: flex;
width: max-content;
animation: slide 30s linear infinite;
}
.reverse { animation-duration: 36s; animation-direction: reverse; }
.row:hover .track, .row:focus-within .track { animation-play-state: paused; }
.list {
display: flex;
gap: 10px;
margin: 0;
padding: 0 10px 0 0;
list-style: none;
}
.tag {
padding: 8px 14px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
}
.reverse .tag:nth-child(odd) { border-color: var(--stage-accent); color: var(--stage-accent); }
@keyframes slide { to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) {
.track { animation: none; }
.row { overflow-x: auto; }
}