Marquee for React
An endless row or column of cards, logos or previews, never of bare words. Stops on hover and focus, drifts with the scroll if you like, still and scrollable with reduced motion.
One of 6 components in Beams, particles and motion
An endless row that stays calm
A marquee runs a row of logos, cards or previews endlessly, a way to show more than fits without a carousel to operate. This one moves cards and pictures, never bare words, pauses on hover and keyboard focus so every item can be read and clicked, and duplicates its items for screen readers only once.
Logo row is the classic band of customer logos below a hero. Two rows of cards runs testimonials or features in opposite directions. Vertical columns lets cards climb in two or three columns beside a headline, a calm alternative for a hero. Each variant can drift with the scroll instead of running on its own.
Keep the speed slow, people should be able to read a card as it passes. Use at least twice as many items as fit on the screen, so the loop is not obvious. With reduced motion the row stands still and scrolls by hand, check that it still looks complete.
Details
What Marquee brings along, read from the registry and the source.
- Category
- Beams, particles and motion
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/marquee.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- English and German, 1 text of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MarqueeProps interface from components/ui/marquee.tsx, with the comments from the code.
childrenrequiredReact.ReactNodeCards, logos with a mark, images or previews. Never a row of bare words.
directionoptional'horizontal' | 'vertical'horizontal runs right to left, vertical bottom to top
reverseoptionalbooleanRun the other way
speedoptionalnumberPixels per second, default 40
gapoptionalnumberSpace between items in px, default 40 (horizontal) or 16 (vertical)
fadeoptionalbooleanSoft fade on both ends, default true
pauseOnHoveroptionalbooleanStop on hover and keyboard focus, default true
scrollShiftoptionalnumberPixels the whole row drifts on top of its run while the page scrolls past, e.g. 120. Positive moves against the run, negative with it. Two rows with opposite signs slide past each other. Default 0, off with reduced motion.
labeloptionalstringName of the region for screen readers, e.g. "Customer logos"
itemClassNameoptionalstringClasses for each item wrapper
Built-in texts
Marquee writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 1 texts
| Key | English | German |
|---|---|---|
| region | English Scrolling list | German Laufende Liste |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-roledescription, so screen readers announce purpose and state.
- Uses the roles clone and region.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 177 lines
'use client';
import { Children, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
const TEXTS = {
en: { region: 'Scrolling list' },
de: { region: 'Laufende Liste' },
};
export interface MarqueeProps {
/** Cards, logos with a mark, images or previews. Never a row of bare words. */
children: React.ReactNode;
/** horizontal runs right to left, vertical bottom to top */
direction?: 'horizontal' | 'vertical';
/** Run the other way */
reverse?: boolean;
/** Pixels per second, default 40 */
speed?: number;
/** Space between items in px, default 40 (horizontal) or 16 (vertical) */
gap?: number;
/** Soft fade on both ends, default true */
fade?: boolean;
/** Stop on hover and keyboard focus, default true */
pauseOnHover?: boolean;
/**
* Pixels the whole row drifts on top of its run while the page scrolls past,
* e.g. 120. Positive moves against the run, negative with it. Two rows with
* opposite signs slide past each other. Default 0, off with reduced motion.
*/
scrollShift?: number;
/** Name of the region for screen readers, e.g. "Customer logos" */
label?: string;
labels?: Partial<(typeof TEXTS)['en']>;
/** Vertical marquees need a height, e.g. h-[420px] */
className?: string;
/** Classes for each item wrapper */
itemClassName?: string;
}
const CSS = `
@keyframes kitfx-marquee-x { to { transform: translate3d(calc(-100% / var(--kitfx-n)), 0, 0); } }
@keyframes kitfx-marquee-y { to { transform: translate3d(0, calc(-100% / var(--kitfx-n)), 0); } }
.kitfx-mq-track { animation-play-state: var(--kitfx-play, running); }
.kitfx-mq[data-pause]:hover .kitfx-mq-track, .kitfx-mq[data-pause]:focus-within .kitfx-mq-track { animation-play-state: paused; }
.kitfx-mq-shift { transition: transform .3s linear; }
@media (prefers-reduced-motion: reduce) {
.kitfx-mq { overflow: auto !important; overscroll-behavior: contain; }
.kitfx-mq-track { animation: none !important; }
.kitfx-mq-shift { transform: none !important; }
.kitfx-mq-copy[data-copy="clone"] { display: none !important; }
}
`;
/**
* An endless row (or column) of cards, logos with a mark, images or previews.
* The list exists once for screen readers, the clones that close the loop are
* hidden and inert. Pauses on hover and focus, and when out of view. With
* reduced motion it is a still row you can scroll by touch, wheel or arrow keys.
*/
export function Marquee({ children, direction = 'horizontal', reverse, speed = 40, gap, fade = true, pauseOnHover = true, scrollShift = 0, label, labels, className, itemClassName }: MarqueeProps) {
const t = useTexts(TEXTS, labels);
const vertical = direction === 'vertical';
const space = gap ?? (vertical ? 16 : 40);
const box = useRef<HTMLDivElement>(null);
const first = useRef<HTMLDivElement>(null);
// How many copies close the loop at the current width, and how long one copy takes
const [copies, setCopies] = useState(2);
const [duration, setDuration] = useState(30);
// One copy in px, the drift wrapper starts one copy early so both directions stay covered
const [inner, setInner] = useState(0);
useEffect(() => {
const el = box.current;
const copy = first.current;
if (!el || !copy) return;
const measure = () => {
const outer = vertical ? el.clientHeight : el.clientWidth;
const size = vertical ? copy.offsetHeight : copy.offsetWidth;
if (!size) return;
setCopies(Math.max(2, Math.ceil(outer / size) + 1) + (scrollShift ? 2 : 0));
setDuration(Math.max(4, Math.round((size / speed) * 10) / 10));
setInner(size);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
ro.observe(copy);
const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
io.observe(el);
return () => {
ro.disconnect();
io.disconnect();
};
}, [vertical, speed, scrollShift]);
// Drift: --kitfx-p runs from 0 (row enters at the bottom) to 1 (it leaves at the top)
useEffect(() => {
const el = box.current;
if (!el || !scrollShift || prefersReducedMotion()) return;
let frame = 0;
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
el.style.setProperty('--kitfx-p', p.toFixed(4));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [scrollShift]);
const mask = fade ? (vertical ? 'linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent)' : undefined) : undefined;
const items = Children.toArray(children);
const list = (clone: boolean, i: number) => (
<div
key={i}
ref={clone ? undefined : first}
data-copy={clone ? 'clone' : 'main'}
aria-hidden={clone || undefined}
inert={clone || undefined}
className={cn('kitfx-mq-copy flex shrink-0', vertical ? 'flex-col' : 'items-center')}
style={vertical ? { gap: space, paddingBottom: space } : { gap: space, paddingRight: space }}
role={clone ? undefined : 'list'}
>
{items.map((child, k) => (
<div key={k} role={clone ? undefined : 'listitem'} className={cn('shrink-0', itemClassName)}>
{child}
</div>
))}
</div>
);
// The drift sits on its own wrapper, so the run keeps its transform
const track = (
<div
className={cn('kitfx-mq-track flex', vertical ? 'w-full flex-col' : 'w-max')}
style={{
['--kitfx-n' as string]: copies,
animation: `${vertical ? 'kitfx-marquee-y' : 'kitfx-marquee-x'} ${duration}s linear infinite${reverse ? ' reverse' : ''}`,
}}
>
{Array.from({ length: copies }, (_, i) => list(i > 0, i))}
</div>
);
// One copy early, which looks the same, so the drift never uncovers an edge
const drift = `calc(${-inner}px + (0.5 - var(--kitfx-p, 0.5)) * ${scrollShift}px)`;
// The focus ring sits on an outer wrapper, the masked edges would cut it off
return (
<div className={cn('relative rounded-lg has-[.kitfx-mq:focus-visible]:outline-2 has-[.kitfx-mq:focus-visible]:outline-offset-4 has-[.kitfx-mq:focus-visible]:outline-ring', className)}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div
ref={box}
role="region"
aria-roledescription="marquee"
aria-label={label ?? t.region}
tabIndex={0}
data-pause={pauseOnHover ? '' : undefined}
className={cn('kitfx-mq kit-no-scrollbar relative size-full overflow-hidden outline-none', fade && !vertical && 'kit-fade-x')}
style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}
>
<div data-clip-ok className={cn('kitfx-mq-shift', vertical ? 'w-full' : 'w-max')} style={scrollShift && inner ? { transform: vertical ? `translate3d(0, ${drift}, 0)` : `translate3d(${drift}, 0, 0)` } : undefined}>
{track}
</div>
</div>
</div>
);
}Frequent questions
How do I install Marquee?
With aiw add @ai-website/marquee. The command puts components/ui/marquee.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Marquee have?
3 variants, Logo row, Two rows of cards and Vertical columns.
Can I translate the texts of Marquee?
Yes. Marquee carries 1 texts of its own in English and German, such as “Scrolling list”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Marquee?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Marquee with aiw_components and install it with aiw_add @ai-website/marquee.