Marquee für React
Eine endlose Reihe oder Spalte aus Karten, Logos oder Vorschauen, nie aus bloßen Wörtern. Hält bei Zeiger und Fokus, gleitet auf Wunsch mit dem Scrollen, bei reduzierter Bewegung ruhig und scrollbar.
Eine von 6 Components in Beams, Partikel und Bewegung
Eine endlose Reihe, die ruhig bleibt
Ein Marquee lässt eine Reihe aus Logos, Cards oder Vorschauen endlos laufen, ein Weg, mehr zu zeigen, als hineinpasst, ohne ein Carousel bedienen zu müssen. Dieses bewegt Cards und Bilder, nie nackte Wörter, hält bei Hover und Tastaturfokus an, damit sich jeder Eintrag lesen und klicken lässt, und gibt seine Einträge Screenreadern nur einmal.
Die Logoreihe ist das klassische Band aus Kundenlogos unter dem Hero. „Zwei Reihen Karten“ lässt Kundenstimmen oder Features gegenläufig laufen. „Senkrechte Spalten“ lässt Cards in zwei oder drei Spalten neben einer Headline aufsteigen, eine ruhige Alternative für einen Hero. Jede Variante kann auch mit dem Scrollen treiben, statt von selbst zu laufen.
Halte das Tempo langsam, eine Card sollte sich im Vorbeiziehen lesen lassen. Nutze mindestens doppelt so viele Einträge, wie auf den Bildschirm passen, dann fällt die Schleife nicht auf. Bei reduzierter Bewegung steht die Reihe still und scrollt von Hand, prüf, dass sie dann vollständig wirkt.
Details
Was Laufband mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Beams, Partikel und Bewegung
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/marquee.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 1 eigener Text
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MarqueeProps aus components/ui/marquee.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
childrenPflichtReact.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
Eingebaute Texte
Diese Texte schreibt Laufband selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 1 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| region | Englisch Scrolling list | Deutsch Laufende Liste |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen clone und region.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 177 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Laufband?
Mit aiw add @ai-website/marquee. Der Befehl legt die Datei components/ui/marquee.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Laufband?
3 Varianten, Logoreihe, Zwei Reihen Karten und Senkrechte Spalten.
Kann ich die Texte von Laufband übersetzen?
Ja. Laufband bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Laufende Liste“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Laufband einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Laufband mit aiw_components und installieren sie mit aiw_add @ai-website/marquee.