React carousel
Picture slides after the APG pattern: a hero with autoplay and pause, cards with the next one peeking in, a gallery with thumbnails and a centered row. Swipe, drag, keyboard, and a plain scroll row without JavaScript.
Slides that respect the keyboard
A carousel shows several pictures or cards in the space of one. This one follows the APG carousel pattern on a native snap scroll row, so swipe, mouse drag, arrow keys and dots all work, and the row stays readable and scrollable without JavaScript.
Hero with autoplay shows full width pictures with a caption on a soft scrim, autoplay rests while the pointer is on it, stops for good once the keyboard enters, and the first button pauses and starts it. Cards with a peek lines up listings with the next card always peeking in, arrows and a count in the title row and a thin line that shows how much of the row is in view. Gallery with thumbnails pairs one large picture with a strip of thumbnails, the current one carries the accent ring. Centered row keeps the current picture in the middle while its neighbours shrink and fade, and after the last one the row starts again.
Let people control the pace, autoplay always needs a visible pause. Put the most important slide first, many visitors never see the second. On phones the card row with a peek tells people there is more without a word.
Variants
Details
What Carousel brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/carousel.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 9 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CarouselProps interface from components/ui/carousel.tsx, with the comments from the code.
slidesrequiredCarouselSlide[]variantoptionalCarouselVariantherocardsthumbnailscentered
labelrequiredstringAccessible 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
Built-in texts
Carousel writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 9 texts
| Key | English | German |
|---|---|---|
| carousel | English carousel | German Karussell |
| slide | English slide | German Folie |
| of | English of | German von |
| previous | English Previous slide | German Vorherige Folie |
| next | English Next slide | German Nächste Folie |
| goTo | English Show slide | German Folie zeigen |
| stop | English Stop the slide show | German Diashow anhalten |
| start | English Start the slide show | German Diashow starten |
| pictures | English Pictures | German Bilder |
Accessibility
Read from the source of the component.
- Carousel handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-current, aria-label, aria-live and aria-roledescription, so screen readers announce purpose and state.
- Uses the role group.
- Announces changes through a live region without moving focus.
- Shows a visible focus ring when used with the keyboard.
- 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. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 633 lines
'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>
);
}Frequent questions
How do I install Carousel?
With aiw add @ai-website/carousel. The command puts components/ui/carousel.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Carousel have?
4 variants, Hero with autoplay, Cards with a peek, Gallery with thumbnails and Centered row. You pick one with the variant prop, for example variant="cards".
Can I translate the texts of Carousel?
Yes. Carousel carries 9 texts of its own in English and German, such as “carousel”, “slide” and “of”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Carousel?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Carousel with aiw_components and install it with aiw_add @ai-website/carousel. Name the variant in your prompt, for example variant="hero", and the assistant writes your content straight into the props.