Carousel (Thumbnails)
Horizontally-advancing carousel (aka hero or marquee carousel) with thumbnail navigation, prev/next buttons, and pause behavior.
Selection
The criteria an agent checks before retrieving this component.
Use when
- Use when thumbnail images are displayed for direct slide selection.
- Use when users can identify slide content from visible preview images before activating a slide.
- Use when thumbnails act as explicit navigation controls for specific slides.
Try a different component when
- Do not use when navigation is limited to dot indicators without image previews (use
carousel.dots). - Do not use when multiple full-size items are visible simultaneously in a scrollable row (use
content-shelf).
Must Haves
Non-negotiable structure. Every generated instance must satisfy these rules.
- Render a carousel container with
aria-roledescription="carousel"and an accessible name (aria-label). - Ensure the carousel container has a semantic HTML5 element or role, such as
<section>orrole="region". - Each slide must have
role="group",aria-roledescription="slide"and anaria-labellike "1 of N". - Slides that are not currently visible must not be rendered, or must be hidden in the DOM (e.g., via the
hiddenattribute), so their content cannot be reached by keyboard or screen readers. - Provide Previous/Next buttons as real
<button>elements, witharia-labellike "Previous Slide" and "Next Slide". - Provide thumbnail navigation as real
<button>elements in normal tab order (no roving tabindex), witharia-labellike "Go to slide 2: {title of slide 2}", and witharia-current="true"on the button corresponding to the active slide. - Provide a Pause/Play button as the first focusable element inside the carousel container.
- Default to paused when prefers-reduced-motion: reduce.
- Pause when keyboard focus enters the carousel region.
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) on each focusable element, including the previous/next buttons, pause button, and thumbnail controls.
Donts
Avoid these accessibility and UX barriers.
- Do not auto-advance the slides without a visible Pause/Play control.
- Do not ignore
prefers-reduced-motion: reduce. - Do not keep moving while the user is interacting (focus inside carousel must pause autoplay).
Customizable
Alternatives and options that give the AI agent some room to move.
- The contents of each slide are customizable. However, if they contain a title, then these should usually be
<h2>.
Golden Pattern
The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.
"use client";
export function CarouselThumbnailsDemo({
ariaLabel = "Featured content",
items = DEFAULT_ITEMS,
autoplay = true,
intervalMs = 5000,
}) {
const [index, setIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const hasUserToggledPauseRef = useRef(false);
const reducedMotionRef = useRef(false);
const timerRef = useRef(null);
const skipFocusPauseRef = useRef(false);
const count = items.length;
// Reduced motion: paused by default.
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => {
reducedMotionRef.current = !!mq.matches;
if (mq.matches) {
setIsPaused(true);
}
};
apply();
// Safari still supports addListener in some versions
if (mq.addEventListener) mq.addEventListener("change", apply);
else mq.addListener(apply);
return () => {
if (mq.removeEventListener) mq.removeEventListener("change", apply);
else mq.removeListener(apply);
};
}, []);
function goTo(nextIndex) {
const clamped = ((nextIndex % count) + count) % count;
setIndex(clamped);
}
function goPrev() {
goTo(index - 1);
}
function goNext() {
goTo(index + 1);
}
function pause() {
setIsPaused(true);
}
function togglePause() {
skipFocusPauseRef.current = false;
hasUserToggledPauseRef.current = true;
setIsPaused((p) => !p);
}
function onPauseButtonPointerDown() {
skipFocusPauseRef.current = true;
}
// Pause autoplay when focus enters the carousel.
function onFocusCapture() {
if (skipFocusPauseRef.current) {
skipFocusPauseRef.current = false;
return;
}
pause();
}
// Autoplay (respects reduced motion, focus-paused state, and user pause).
useEffect(() => {
if (!autoplay || isPaused || reducedMotionRef.current) return;
// Functional update avoids stale-closure reads of the current index.
timerRef.current = window.setInterval(() => {
setIndex((i) => (i + 1) % count);
}, intervalMs);
return () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [autoplay, isPaused, intervalMs, count]);
const active = items[index];
// aria-live: off while moving, polite when paused so changes can be announced if user moves slides.
const ariaLive = isPaused ? "polite" : "off";
return (
<section
aria-roledescription="carousel"
aria-label={ariaLabel}
onFocusCapture={onFocusCapture}
style={{
position: "relative",
maxWidth: 960,
margin: "0 auto",
padding: 16,
background: "#fff",
color: "#111",
borderRadius: 12,
}}
>
{/* Slides viewport */}
<div
aria-live={ariaLive}
style={{
position: "relative",
overflow: "hidden",
borderRadius: 12,
minHeight: 400,
background: "#000",
}}
>
<button
type="button"
onPointerDown={onPauseButtonPointerDown}
onClick={togglePause}
aria-label={isPaused ? "Play automatic rotation" : "Pause automatic rotation"}
aria-pressed={isPaused}
style={pauseButtonStyle}
>
<span aria-hidden="true">
{isPaused ? "[play-icon]" : "[pause-icon]"}
</span>
</button>
{/* Slide */}
<div
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}`}
style={{
display: "grid",
gridTemplateColumns: "1fr",
alignItems: "end",
minHeight: 400,
padding: "16px 72px 72px",
backgroundImage: active.image ? `url(${active.image})` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div
style={{
maxWidth: 520,
background: "#000",
color: "#fff",
padding: 12,
borderRadius: 10,
}}
>
<h2 style={{ margin: 0, fontSize: 24 }}>{active.title}</h2>
<p style={{ marginTop: 8, marginBottom: 12, lineHeight: 1.4 }}>
{active.description}
</p>
<a
href={active.href}
style={{
display: "inline-block",
padding: "10px 12px",
borderRadius: 10,
background: "#fff",
color: "#000",
textDecoration: "none",
fontWeight: 600,
}}
>
View details
</a>
</div>
</div>
{/* Prev / Next (inside visible slide area) */}
<button
type="button"
onClick={() => {
pause();
goPrev();
}}
aria-label="Previous slide"
style={navButtonStyle("left")}
>
‹
</button>
<button
type="button"
onClick={() => {
pause();
goNext();
}}
aria-label="Next slide"
style={navButtonStyle("right")}
>
›
</button>
{/* Thumbnail navigation overlay (inside viewport, outside slide node) */}
<div
style={{
position: "absolute",
right: 12,
bottom: 12,
zIndex: 2,
}}
>
<div style={{ display: "flex", gap: 10 }} aria-label="Choose a slide">
{items.map((item, i) => {
const isActive = i === index;
return (
<button
key={i}
type="button"
onClick={() => {
pause();
goTo(i);
}}
aria-label={`Go to slide ${i + 1}: ${item.title}`}
aria-current={isActive ? "true" : undefined}
style={thumbnailButtonStyle(isActive)}
>
<span
aria-hidden="true"
style={{
display: "block",
width: "100%",
height: "100%",
borderRadius: 10,
backgroundImage: item.thumbnail ? `url(${item.thumbnail})` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
</button>
);
})}
</div>
</div>
</div>
</section>
);
}
function navButtonStyle(side) {
return {
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
[side]: 12,
width: 44,
height: 44,
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(0,0,0,0.45)",
color: "#fff",
display: "grid",
placeItems: "center",
cursor: "pointer",
fontSize: 28,
lineHeight: 1,
};
}
const pauseButtonStyle = {
position: "absolute",
top: 12,
left: 12,
zIndex: 2,
width: 40,
height: 40,
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(0,0,0,0.6)",
color: "#fff",
display: "grid",
placeItems: "center",
cursor: "pointer",
};
function thumbnailButtonStyle(active) {
return {
width: 72,
height: 44,
padding: 0,
borderRadius: 10,
border: active ? "2px solid #111" : "1px solid rgba(0,0,0,0.35)",
background: active ? "rgba(0,0,0,0.06)" : "transparent",
cursor: "pointer",
};
}
const DEFAULT_ITEMS = [
{
title: "Neighbors",
description: "A chaotic dispute spirals. Watch the latest episode now.",
href: "#",
image: "https://picsum.photos/seed/hero-1/1200/600",
thumbnail: "https://picsum.photos/seed/thumb-1/240/140",
},
{
title: "UCLA at Michigan",
description: "Tip-off at 12:45 PM ET. Catch it live.",
href: "#",
image: "https://picsum.photos/seed/hero-2/1200/600",
thumbnail: "https://picsum.photos/seed/thumb-2/240/140",
},
{
title: "Fire Country",
description: "A risky mission tests loyalties and nerves.",
href: "#",
image: "https://picsum.photos/seed/hero-3/1200/600",
thumbnail: "https://picsum.photos/seed/thumb-3/240/140",
},
];
Acceptance Checks
The component’s test spec — an optional body of checks for verification.
Semantics
- The carousel container has
aria-roledescription="carousel"and an accessible name. - Each slide has
aria-roledescription="slide"and exposes position (e.g., "2 of 3"). - Content of non-visible slides is not reachable by keyboard or screen reader.
Autoplay
- With
prefers-reduced-motion: reduce, autoplay is paused by default. - Tabbing into the carousel pauses autoplay.
- Autoplay does not run while paused.
Keyboard
- Previous and Next buttons are reachable via Tab and move one slide per activation.
- Each thumbnail is reachable via Tab and activates its corresponding slide.
- Each thumbnail's
aria-labelincludes not only "Go to slide N" but also a simple title or name for the corresponding slide. - The active thumbnail exposes state (e.g.,
aria-current="true").
Content
- Each slide includes a visible title (
<h2>), a short description, and one primary CTA link.
Screen Reader
- When changing slides while paused, the carousel name and slide position are announced without duplicate announcements.