Karussell für React
Bild-Folien nach dem APG-Muster: ein Auftakt mit Autoplay und Pause, Karten mit Vorschau der nächsten, eine Galerie mit Vorschaubildern und eine mittige Reihe. Wischen, Ziehen, Tastatur und ohne JavaScript eine einfache Scrollreihe.
Slides, die die Tastatur respektieren
Ein Karussell zeigt mehrere Bilder oder Karten auf dem Platz von einem. Dieses folgt dem APG-Muster für Karussells auf einer nativen Snap-Scroll-Reihe, Wischen, Ziehen mit der Maus, Pfeiltasten und Punkte funktionieren also alle, und ohne JavaScript bleibt die Reihe lesbar und scrollbar.
„Auftakt mit Autoplay“ zeigt Bilder in voller Breite mit Text auf weichem Verlauf, Autoplay ruht, solange der Zeiger darauf liegt, stoppt endgültig, sobald die Tastatur hineinkommt, und der erste Knopf hält es an und startet es. „Karten mit Vorschau“ reiht Angebote, in die immer die nächste Karte hineinragt, mit Pfeilen und Zähler in der Titelzeile und einer feinen Linie, die zeigt, wie viel sichtbar ist. „Galerie mit Vorschaubildern“ verbindet ein großes Bild mit einer Leiste kleiner Bilder, das aktuelle trägt den Akzentring. Die „Mittige Reihe“ hält das aktuelle Bild in der Mitte, die Nachbarn werden kleiner und blasser, nach dem letzten beginnt die Reihe von vorn.
Lass Menschen das Tempo bestimmen, Autoplay braucht immer eine sichtbare Pause. Stell das wichtigste Bild an den Anfang, viele sehen das zweite nie. Am Telefon sagt die Kartenreihe mit Vorschau ohne Worte, dass es mehr gibt.
Variants
Details
Was Karussell mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Media
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/carousel.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 9 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CarouselProps aus components/ui/carousel.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
slidesPflichtCarouselSlide[]variantoptionalCarouselVariantherocardsthumbnailscentered
labelPflichtstringAccessible name, e.g. "New in the shop"
headingoptionalReact.ReactNodeTitle row above the cards (cards variant)
autoplayoptionalnumberAdvance on its own every n ms. Off with reduced motion, paused on hover, stopped by keyboard focus.
loopoptionalbooleanAfter the last slide comes the first again
aspectoptionalstringRatio of the pictures, e.g. "16/9"
startIndexoptionalnumberonIndexChangeoptional(index: number) => void
Eingebaute Texte
Diese Texte schreibt Karussell selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 9 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| carousel | Englisch carousel | Deutsch Karussell |
| slide | Englisch slide | Deutsch Folie |
| of | Englisch of | Deutsch von |
| previous | Englisch Previous slide | Deutsch Vorherige Folie |
| next | Englisch Next slide | Deutsch Nächste Folie |
| goTo | Englisch Show slide | Deutsch Folie zeigen |
| stop | Englisch Stop the slide show | Deutsch Diashow anhalten |
| start | Englisch Start the slide show | Deutsch Diashow starten |
| pictures | Englisch Pictures | Deutsch Bilder |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Karussell reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-current, aria-label, aria-live und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- 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. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 633 Zeilen
'use client';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
/*
* Carousel after the APG pattern: a region with the role description
* "carousel", slides as labelled groups ("2 of 5"), previous and next
* buttons, a picker (dots or thumbnails) and, with autoplay, a rotation
* button that comes first. Autoplay pauses while the pointer rests on it,
* stops for good once keyboard focus enters, waits while it is off screen or
* the tab is hidden and never starts with reduced motion.
*
* The slides sit in a native scroll row with snap points. Fingers swipe it
* natively, a mouse drags it (pointer events), and without JavaScript it is
* still a row of pictures that scrolls sideways.
*/
export type CarouselVariant = 'hero' | 'cards' | 'thumbnails' | 'centered';
export interface CarouselSlide {
/** Picture of the slide, a drawn scene, a photo or a screen */
media: React.ReactNode;
/** Smaller picture for the thumbnail strip, defaults to media */
thumb?: React.ReactNode;
eyebrow?: string;
title?: string;
description?: string;
/** Quiet line under the card text, e.g. a price or a date */
meta?: string;
href?: string;
/** Text of the link on hero slides */
cta?: string;
}
const TEXTS = {
en: {
carousel: 'carousel',
slide: 'slide',
of: 'of',
previous: 'Previous slide',
next: 'Next slide',
goTo: 'Show slide',
stop: 'Stop the slide show',
start: 'Start the slide show',
pictures: 'Pictures',
},
de: {
carousel: 'Karussell',
slide: 'Folie',
of: 'von',
previous: 'Vorherige Folie',
next: 'Nächste Folie',
goTo: 'Folie zeigen',
stop: 'Diashow anhalten',
start: 'Diashow starten',
pictures: 'Bilder',
},
};
type CarouselTexts = (typeof TEXTS)['en'];
export interface CarouselProps {
slides: CarouselSlide[];
variant?: CarouselVariant;
/** Accessible name, e.g. "New in the shop" */
label: string;
/** Title row above the cards (cards variant) */
heading?: React.ReactNode;
/** Advance on its own every n ms. Off with reduced motion, paused on hover, stopped by keyboard focus. */
autoplay?: number;
/** After the last slide comes the first again */
loop?: boolean;
/** Ratio of the pictures, e.g. "16/9" */
aspect?: string;
startIndex?: number;
onIndexChange?: (index: number) => void;
labels?: Partial<CarouselTexts>;
className?: string;
}
const STYLES =
'.kitfx-car-bar{transform-origin:left;animation:kitfx-car-bar var(--car-ms) linear both}@keyframes kitfx-car-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}' +
'.kitfx-car-paused .kitfx-car-bar{animation-play-state:paused}' +
'@media (prefers-reduced-motion:reduce){.kitfx-car-bar{animation:none;transform:scaleX(1)}}';
const ROUND_BTN =
'relative grid size-10 shrink-0 cursor-pointer place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] outline-none transition-[background-color,color,scale,opacity] duration-200 ease-[var(--ease-kit)] after:absolute after:-inset-0.5 after:rounded-full after:content-[""] hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring active:scale-95 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-background motion-reduce:active:scale-100 max-sm:size-11';
/** The heart of every variant: where the row is, how to move it, how a mouse drags it */
function useCarousel(count: number, opts: { loop: boolean; center: boolean; startIndex: number; onIndexChange?: (i: number) => void }) {
const trackRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(opts.startIndex);
const [edges, setEdges] = useState({ start: true, end: count <= 1 });
const indexRef = useRef(index);
const onChange = useRef(opts.onIndexChange);
onChange.current = opts.onIndexChange;
const slidesOf = (t: HTMLDivElement) => [...t.querySelectorAll<HTMLElement>(':scope > [data-slide]')];
const target = useCallback(
(t: HTMLDivElement, el: HTMLElement) => {
const max = t.scrollWidth - t.clientWidth;
const pad = parseFloat(getComputedStyle(t).paddingInlineStart) || 0;
const x = opts.center ? el.offsetLeft - (t.clientWidth - el.offsetWidth) / 2 : el.offsetLeft - pad;
return Math.max(0, Math.min(max, x));
},
[opts.center],
);
const sync = useCallback(() => {
const t = trackRef.current;
if (!t) return;
const slides = slidesOf(t);
let best = 0;
let dist = Infinity;
slides.forEach((el, i) => {
const d = Math.abs(target(t, el) - t.scrollLeft);
if (d < dist - 1) {
dist = d;
best = i;
}
});
const max = t.scrollWidth - t.clientWidth;
setEdges({ start: t.scrollLeft <= 2, end: t.scrollLeft >= max - 2 });
if (best !== indexRef.current) {
indexRef.current = best;
setIndex(best);
onChange.current?.(best);
}
}, [target]);
const goTo = useCallback(
(i: number, smooth = true) => {
const t = trackRef.current;
if (!t || !count) return;
const n = opts.loop ? (i + count) % count : Math.max(0, Math.min(count - 1, i));
const el = slidesOf(t)[n];
if (!el) return;
t.scrollTo({ left: target(t, el), behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'auto' });
// The picker follows at once, the scroll event confirms it
indexRef.current = n;
setIndex(n);
onChange.current?.(n);
},
[count, opts.loop, target],
);
// Start position and measuring on scroll and resize
useEffect(() => {
const t = trackRef.current;
if (!t) return;
if (opts.startIndex) goTo(opts.startIndex, false);
let raf = 0;
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(sync);
};
t.addEventListener('scroll', onScroll, { passive: true });
const ro = new ResizeObserver(onScroll);
ro.observe(t);
sync();
return () => {
cancelAnimationFrame(raf);
t.removeEventListener('scroll', onScroll);
ro.disconnect();
};
}, [sync]); // eslint-disable-line react-hooks/exhaustive-deps
// Mouse drag. Fingers keep the native swipe, a pen or mouse pulls the row and lets it snap.
const drag = useRef<{ x: number; left: number; from: number; moved: boolean; id: number } | null>(null);
const pointer = {
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch' || e.button !== 0) return;
const t = trackRef.current;
if (!t) return;
drag.current = { x: e.clientX, left: t.scrollLeft, from: indexRef.current, moved: false, id: e.pointerId };
},
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
const t = trackRef.current;
if (!d || !t) return;
const dx = e.clientX - d.x;
if (!d.moved && Math.abs(dx) < 6) return;
if (!d.moved) {
d.moved = true;
t.setPointerCapture(d.id);
t.style.scrollSnapType = 'none';
t.style.scrollBehavior = 'auto';
t.style.cursor = 'grabbing';
}
t.scrollLeft = d.left - dx;
},
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
const t = trackRef.current;
drag.current = null;
if (!d || !t || !d.moved) return;
t.style.cursor = '';
const dx = e.clientX - d.x;
t.style.scrollSnapType = '';
t.style.scrollBehavior = '';
// A clear pull moves at least one slide, a small one snaps back
const step = Math.abs(dx) > 48 ? (dx < 0 ? 1 : -1) : 0;
const goal = opts.loop ? Math.max(0, Math.min(count - 1, d.from + step)) : d.from + step;
goTo(goal);
// The click that ends a drag must not open a link inside the slide
const stop = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
};
t.addEventListener('click', stop, { capture: true, once: true });
setTimeout(() => t.removeEventListener('click', stop, { capture: true }), 0);
},
};
return { trackRef, index, edges, goTo, pointer };
}
/** Autoplay that follows the APG rules */
function useAutoplay(ms: number | undefined, rootRef: React.RefObject<HTMLElement | null>) {
const [playing, setPlaying] = useState(false);
const [hover, setHover] = useState(false);
const [visible, setVisible] = useState(true);
useEffect(() => {
if (ms && !prefersReducedMotion()) setPlaying(true);
}, [ms]);
useEffect(() => {
const el = rootRef.current;
if (!el || !ms) return;
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting && document.visibilityState === 'visible'));
io.observe(el);
const onVis = () => setVisible(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', onVis);
return () => {
io.disconnect();
document.removeEventListener('visibilitychange', onVis);
};
}, [ms, rootRef]);
const running = Boolean(ms) && playing && !hover && visible;
return { playing, setPlaying, running, setHover };
}
function SlideMedia({ slide, aspect, className, rounded = 'rounded-xl' }: { slide: CarouselSlide; aspect: string; className?: string; rounded?: string }) {
return (
<div data-clip-ok className={cn('relative overflow-hidden bg-muted', rounded, className)} style={{ aspectRatio: aspect }}>
<div className="pointer-events-none absolute inset-0 select-none [&>*]:size-full [&_img]:object-cover">{slide.media}</div>
</div>
);
}
/** A round dot that grows into a short bar for the current slide, with the autoplay time filling it */
function Dots({ count, index, goTo, t, running, ms, label }: { count: number; index: number; goTo: (i: number) => void; t: CarouselTexts; running: boolean; ms?: number; label: string }) {
return (
<div role="group" aria-label={label} className="flex items-center">
{Array.from({ length: count }, (_, i) => {
const on = i === index;
return (
<button
key={i}
type="button"
aria-label={`${t.goTo} ${i + 1} ${t.of} ${count}`}
aria-current={on || undefined}
onClick={() => goTo(i)}
className="group/dot relative grid h-11 w-6 cursor-pointer place-items-center outline-none max-sm:w-7 [&:focus-visible>span]:outline-2 [&:focus-visible>span]:outline-offset-[3px] [&:focus-visible>span]:outline-solid [&:focus-visible>span]:outline-ring"
>
<span
className={cn(
'relative block h-1.5 overflow-hidden rounded-full transition-[width,background-color] duration-300 ease-[var(--ease-kit)]',
on ? 'w-5 bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]' : 'w-1.5 bg-[color-mix(in_oklab,var(--foreground)_26%,transparent)] group-hover/dot:bg-[color-mix(in_oklab,var(--foreground)_50%,transparent)]',
)}
>
{on && <span key={`${index}-${running}`} className={cn('absolute inset-0 rounded-full bg-foreground', running && ms ? 'kitfx-car-bar' : '')} style={{ '--car-ms': `${ms ?? 0}ms` } as React.CSSProperties} />}
</span>
</button>
);
})}
</div>
);
}
function Arrows({ prev, next, t, atStart, atEnd }: { prev: () => void; next: () => void; t: CarouselTexts; atStart: boolean; atEnd: boolean }) {
return (
<div className="flex items-center gap-2">
<button type="button" aria-label={t.previous} onClick={prev} disabled={atStart} className={ROUND_BTN}>
<Icon name="chevron-left" size={18} />
</button>
<button type="button" aria-label={t.next} onClick={next} disabled={atEnd} className={ROUND_BTN}>
<Icon name="chevron-right" size={18} />
</button>
</div>
);
}
/**
* Slides of pictures with previous and next, dots or thumbnails, swipe and drag, and an optional autoplay with a pause button.
* <Carousel variant="hero" label="Season highlights" slides={[{ media: <img … />, title: '…' }]} autoplay={6000} />
*/
export function Carousel({ slides, variant = 'hero', label, heading, autoplay, loop, aspect, startIndex = 0, onIndexChange, labels, className }: CarouselProps) {
const t = useTexts(TEXTS, labels);
const id = useId();
const count = slides.length;
const wraps = loop ?? (variant === 'hero' || variant === 'centered');
const center = variant === 'centered';
const { trackRef, index, edges, goTo, pointer } = useCarousel(count, { loop: wraps, center, startIndex, onIndexChange });
const rootRef = useRef<HTMLElement>(null);
const auto = useAutoplay(autoplay, rootRef);
const ratio = aspect ?? (variant === 'hero' ? '16/9' : variant === 'cards' ? '4/3' : variant === 'thumbnails' ? '3/2' : '4/3');
// Autoplay steps
useEffect(() => {
if (!auto.running || !autoplay) return;
const timer = window.setTimeout(() => goTo(index + 1), autoplay);
return () => window.clearTimeout(timer);
}, [auto.running, autoplay, index, goTo]);
// Centered: neighbours shrink and dim with their distance from the middle, written straight to the elements
useEffect(() => {
if (!center) return;
const tr = trackRef.current;
if (!tr) return;
let raf = 0;
const paint = () => {
const mid = tr.scrollLeft + tr.clientWidth / 2;
const calm = prefersReducedMotion();
tr.querySelectorAll<HTMLElement>(':scope > [data-slide]').forEach((el) => {
const d = Math.min(1, Math.abs(el.offsetLeft + el.offsetWidth / 2 - mid) / el.offsetWidth);
const inner = el.firstElementChild as HTMLElement | null;
if (!inner) return;
inner.style.transform = calm ? '' : `scale(${round(1 - d * 0.1, 3)})`;
inner.style.opacity = String(round(1 - d * 0.45, 3));
});
};
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(paint);
};
paint();
tr.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(raf);
tr.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [center, trackRef]);
// Thumbnails: the strip keeps the current one in view without moving the page
const stripRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const s = stripRef.current;
const el = s?.querySelectorAll<HTMLElement>('button')[index];
if (!s || !el) return;
const x = el.offsetLeft - (s.clientWidth - el.offsetWidth) / 2;
s.scrollTo({ left: Math.max(0, x), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [index]);
const prev = () => goTo(index - 1);
const next = () => {
if (!wraps && edges.end) return;
goTo(index + 1);
};
const atStart = !wraps && (index === 0 || edges.start);
const atEnd = !wraps && edges.end;
const onKeyDown = (e: React.KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === 'ArrowRight') {
e.preventDefault();
next();
(e.target as HTMLElement).closest('[data-picker]')?.querySelectorAll<HTMLElement>('button')[Math.min(count - 1, wraps ? (index + 1) % count : index + 1)]?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
prev();
(e.target as HTMLElement).closest('[data-picker]')?.querySelectorAll<HTMLElement>('button')[wraps ? (index - 1 + count) % count : Math.max(0, index - 1)]?.focus();
}
};
const rotation = autoplay ? (
<button
type="button"
data-rotation
aria-label={auto.playing ? t.stop : t.start}
onClick={() => auto.setPlaying(!auto.playing)}
className={ROUND_BTN}
>
<Icon name={auto.playing ? 'pause' : 'play'} size={16} />
</button>
) : null;
const counter = (
<p className="min-w-[3.25rem] text-[13px] tabular-nums text-muted-foreground" aria-hidden>
<span className="font-medium text-foreground">{index + 1}</span> / {count}
</p>
);
const slideLabel = (i: number) => `${i + 1} ${t.of} ${count}${slides[i].title ? `, ${slides[i].title}` : ''}`;
const track = (slideClass: string, render: (s: CarouselSlide, i: number) => React.ReactNode, trackClass?: string) => (
<div
ref={trackRef}
id={`${id}-track`}
aria-live={auto.running ? 'off' : 'polite'}
{...pointer}
className={cn(
'kit-no-scrollbar relative flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch] motion-safe:scroll-smooth',
'cursor-grab select-none active:cursor-grabbing',
trackClass,
)}
>
{slides.map((s, i) => (
<div key={i} data-slide role="group" aria-roledescription={t.slide} aria-label={slideLabel(i)} className={cn('shrink-0', center ? 'snap-center' : 'snap-start', slideClass)}>
{render(s, i)}
</div>
))}
</div>
);
let body: React.ReactNode;
if (variant === 'hero') {
body = (
<>
{track(
'w-full',
(s) => (
<div className="relative">
<SlideMedia slide={s} aspect={ratio} rounded="rounded-none" className="max-sm:aspect-[4/5]!" />
{(s.title || s.description) && (
<div className="dark absolute inset-x-0 bottom-0 flex flex-col items-start gap-2 bg-[linear-gradient(to_top,rgb(0_0_0/0.72),rgb(0_0_0/0.38)_55%,transparent)] px-5 pb-6 pt-24 text-foreground sm:px-8 sm:pb-8 lg:px-10 lg:pb-10">
{s.eyebrow && <p className="text-[12px] font-medium uppercase tracking-[0.09em] text-[rgb(255_255_255/0.78)]">{s.eyebrow}</p>}
{s.title && <h3 className="max-w-xl text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-white sm:text-h3">{s.title}</h3>}
{s.description && <p className="max-w-lg text-pretty text-sm leading-relaxed text-[rgb(255_255_255/0.82)] sm:text-[15px]">{s.description}</p>}
{s.href && s.cta && (
<a
href={s.href}
draggable={false}
className="relative mt-2 inline-flex h-10 items-center gap-1.5 rounded-lg bg-white px-4 text-sm font-medium text-[oklch(0.2_0.01_264)] outline-none transition-[background-color,scale] duration-200 after:absolute after:-inset-y-0.5 after:inset-x-0 after:content-[''] hover:bg-[oklch(0.93_0.005_264)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-white active:scale-[0.97] max-sm:h-11"
>
{s.cta}
<Icon name="arrow-right" size={15} />
</a>
)}
</div>
)}
</div>
),
'rounded-2xl border border-border',
)}
<div className="mt-3 flex items-center gap-3 sm:mt-4">
{rotation}
<div data-picker className="flex min-w-0 flex-1 justify-center sm:justify-start">
<Dots count={count} index={index} goTo={goTo} t={t} running={auto.running} ms={autoplay} label={label} />
</div>
<span className="max-sm:hidden">{counter}</span>
<Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
</div>
</>
);
} else if (variant === 'cards') {
body = (
<>
<div className="mb-5 flex items-end justify-between gap-4 sm:mb-6">
<div className="min-w-0">{heading}</div>
<div className="flex shrink-0 items-center gap-3">
{rotation}
<span className="max-sm:hidden">{counter}</span>
<Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
</div>
</div>
{track(
// The next card always peeks in, it tells the row goes on
'w-[82%] sm:w-[calc((100%-1rem)/2.25)] lg:w-[calc((100%-2.5rem)/3.3)]',
(s) => (
<article className="group/card flex h-full flex-col">
<SlideMedia slide={s} aspect={ratio} className="transition-[box-shadow] duration-300 group-hover/card:shadow-[var(--shadow-md)]" />
<div className="flex flex-1 flex-col pt-3.5">
{s.eyebrow && <p className="text-[12px] font-medium text-subtle-foreground">{s.eyebrow}</p>}
{s.title && (
<h3 className="mt-0.5 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-foreground">
{s.href ? (
<a href={s.href} draggable={false} className="rounded-sm outline-none after:absolute after:inset-0 after:content-[''] hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring">
{s.title}
</a>
) : (
s.title
)}
</h3>
)}
{s.description && <p className="mt-1 line-clamp-2 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</p>}
{s.meta && <p className="mt-auto pt-2.5 text-[13px] font-medium tabular-nums text-foreground">{s.meta}</p>}
</div>
</article>
),
'gap-4 lg:gap-5 max-sm:-mx-4 max-sm:px-4 max-sm:scroll-px-4 [&>[data-slide]]:relative',
)}
<ScrollLine trackRef={trackRef} />
</>
);
} else if (variant === 'thumbnails') {
body = (
<>
{track('w-full', (s) => <SlideMedia slide={s} aspect={ratio} rounded="rounded-none" />, 'rounded-2xl border border-border')}
<div className="mt-3 flex items-start justify-between gap-4">
<div className="min-w-0" aria-live="polite">
{slides[index]?.title && <p className="truncate text-sm font-medium text-foreground">{slides[index].title}</p>}
{slides[index]?.description && <p className="mt-0.5 line-clamp-2 text-[13px] text-muted-foreground">{slides[index].description}</p>}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="max-sm:hidden">{counter}</span>
<Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
</div>
</div>
<div
ref={stripRef}
data-picker
role="group"
aria-label={`${t.pictures}, ${label}`}
className={cn('kit-no-scrollbar relative -mx-1 mt-4 flex gap-2 overflow-x-auto px-1 py-1.5', count > 5 && 'max-sm:kit-fade-end', count > 8 && 'kit-fade-end')}
>
{slides.map((s, i) => {
const on = i === index;
return (
<button
key={i}
type="button"
aria-label={`${t.goTo} ${i + 1} ${t.of} ${count}${s.title ? `, ${s.title}` : ''}`}
aria-current={on || undefined}
onClick={() => goTo(i)}
className={cn(
'relative aspect-[4/3] w-[4.5rem] shrink-0 cursor-pointer overflow-hidden rounded-lg outline-none transition-[opacity,box-shadow] duration-200 sm:w-24',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
on ? 'opacity-100 shadow-[0_0_0_2px_var(--background),0_0_0_4px_var(--primary)]' : 'opacity-60 shadow-[0_0_0_1px_var(--border)] hover:opacity-100',
)}
>
<span data-clip-ok className="pointer-events-none absolute inset-0 [&>*]:size-full">
{s.thumb ?? s.media}
</span>
</button>
);
})}
</div>
</>
);
} else {
// centered
body = (
<>
{track(
// Container units: the slide and the side room are shares of the same width, so the slide sits exactly in the middle
'w-[78cqw] px-1.5 sm:w-[62cqw] sm:px-2.5 lg:w-[48cqw]',
(s, i) => (
<figure className="origin-center transition-[opacity] duration-300 will-change-transform">
<SlideMedia slide={s} aspect={ratio} rounded="rounded-2xl" className="shadow-[var(--shadow-md)]" />
<figcaption className={cn('mt-4 px-1 text-center transition-opacity duration-300', i === index ? 'opacity-100' : 'opacity-0 max-lg:invisible')}>
{s.title && <span className="block text-[15px] font-semibold text-foreground">{s.title}</span>}
{s.description && <span className="mx-auto mt-1 block max-w-md text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</span>}
</figcaption>
</figure>
),
// Room on both sides so the first and the last slide can sit in the middle
'px-[11cqw] sm:px-[19cqw] lg:px-[26cqw]',
)}
<div className="mt-4 flex items-center justify-center gap-3">
{rotation}
<button type="button" aria-label={t.previous} onClick={prev} className={ROUND_BTN}>
<Icon name="chevron-left" size={18} />
</button>
<div data-picker>
<Dots count={count} index={index} goTo={goTo} t={t} running={auto.running} ms={autoplay} label={label} />
</div>
<button type="button" aria-label={t.next} onClick={next} className={ROUND_BTN}>
<Icon name="chevron-right" size={18} />
</button>
</div>
</>
);
}
return (
<section
ref={rootRef}
aria-roledescription={t.carousel}
aria-label={label}
onKeyDown={onKeyDown}
onPointerEnter={(e) => e.pointerType === 'mouse' && auto.setHover(true)}
onPointerLeave={() => auto.setHover(false)}
onFocus={(e) => {
// APG: keyboard focus inside stops the rotation for good, the rotation button is the way back
if (autoplay && (e.target as HTMLElement).matches(':focus-visible') && !(e.target as HTMLElement).closest('[data-rotation]')) auto.setPlaying(false);
}}
className={cn('@container relative w-full min-w-0', !auto.running && 'kitfx-car-paused', className)}
>
<style href="kitfx-car" precedence="default">
{STYLES}
</style>
{body}
</section>
);
}
/** A thin line under a card row: how much of the row is in view and where */
function ScrollLine({ trackRef }: { trackRef: React.RefObject<HTMLDivElement | null> }) {
const [state, setState] = useState({ w: 30, x: 0 });
useEffect(() => {
const t = trackRef.current;
if (!t) return;
let raf = 0;
const update = () => {
const w = t.scrollWidth > 0 ? Math.min(100, (t.clientWidth / t.scrollWidth) * 100) : 100;
const max = t.scrollWidth - t.clientWidth;
const x = max > 0 ? (t.scrollLeft / max) * (100 - w) : 0;
setState({ w: round(w, 1), x: round(x, 1) });
};
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(update);
};
update();
t.addEventListener('scroll', onScroll, { passive: true });
const ro = new ResizeObserver(onScroll);
ro.observe(t);
return () => {
cancelAnimationFrame(raf);
t.removeEventListener('scroll', onScroll);
ro.disconnect();
};
}, [trackRef]);
return (
<div aria-hidden className="relative mt-5 h-0.5 overflow-hidden rounded-full bg-track">
<span className="absolute inset-y-0 rounded-full bg-foreground" style={{ left: `${state.x}%`, width: `${state.w}%` }} />
</div>
);
}Häufige Fragen
Wie installiere ich Karussell?
Mit aiw add @ai-website/carousel. Der Befehl legt die Datei components/ui/carousel.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Karussell?
4 Varianten, Auftakt mit Autoplay, Karten mit Vorschau, Galerie mit Vorschaubildern und Mittige Reihe. Du wählst sie mit der Prop variant, zum Beispiel variant="cards".
Kann ich die Texte von Karussell übersetzen?
Ja. Karussell bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Karussell“, „Folie“ und „von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Karussell einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Karussell mit aiw_components und installieren sie mit aiw_add @ai-website/carousel. Nenn im Prompt die Variante, etwa variant="hero", dann schreibt der Assistent deine Inhalte direkt in die Props.