Image Carousel
Machine-coding brief for an image carousel — slides, controls, keyboard, autoplay pause, a11y, and reduced motion.
- machine-coding
- interview
- react
- a11y
Problem statement
Build an image carousel / slideshow: N images, next/prev, optional dots, optional autoplay. Interviewers score index wrapping, focusable controls, autoplay that respects reduced motion and hover/focus pause, and layout stability (no CLS from images).
Requirements
Must have
- Show one slide at a time from
images: { src, alt }[] - Prev / next buttons (wrap around or clamp — clarify)
- Dot indicators or “3 / 12” status
- Keyboard: arrows when carousel focused
- Images have meaningful
alt - Controls have accessible names
Should have
- Autoplay with interval; pause on hover, focus, and
prefers-reduced-motion - Swipe on touch (pointer events)
- Lazy-load non-active images
Nice to have
- Smooth transform transition
- Thumbnail strip
- Infinite loop clone technique
Planning (5 minutes out loud)
- Index state modulo length
- Aspect ratio box to reserve space
- Autoplay with cleanup + visibility pause
- MVP — index + buttons + dots; then autoplay + a11y live region
- Don’t autoplay by default without reduced-motion check in production stories
Architecture
Carousel
├── Viewport
│ └── Slide(s)
├── PrevButton / NextButton
├── Dots
└── LiveStatus (sr-only)
API
type Slide = { src: string; alt: string };
type CarouselProps = {
images: Slide[];
initialIndex?: number;
autoplayMs?: number | null; // null = off
loop?: boolean; // default true
onIndexChange?: (index: number) => void;
};
Implementation sketch
function Carousel({
images,
initialIndex = 0,
autoplayMs = null,
loop = true,
}: CarouselProps) {
const [index, setIndex] = useState(initialIndex);
const n = images.length;
const reduceMotion = usePrefersReducedMotion();
const go = useCallback(
(delta: number) => {
setIndex((i) => {
const next = i + delta;
if (loop) return ((next % n) + n) % n;
return Math.min(n - 1, Math.max(0, next));
});
},
[loop, n]
);
useEffect(() => {
if (!autoplayMs || reduceMotion || n <= 1) return;
const id = window.setInterval(() => go(1), autoplayMs);
return () => window.clearInterval(id);
}, [autoplayMs, reduceMotion, go, n, index]); // reset interval on user nav if desired
if (n === 0) return <p>No images</p>;
const slide = images[index];
return (
<div
className="carousel"
onKeyDown={(e) => {
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
}}
>
<div className="viewport" style={{ aspectRatio: "16 / 9" }}>
<img src={slide.src} alt={slide.alt} />
</div>
<button type="button" aria-label="Previous slide" onClick={() => go(-1)}>
Prev
</button>
<button type="button" aria-label="Next slide" onClick={() => go(1)}>
Next
</button>
<div className="dots" role="tablist" aria-label="Slides">
{images.map((img, i) => (
<button
key={img.src + i}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
/>
))}
</div>
<div className="sr-only" aria-live="polite" aria-atomic="true">
Slide {index + 1} of {n}: {slide.alt}
</div>
</div>
);
}
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const fn = () => setReduced(mq.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return reduced;
}
Pause autoplay on hover/focus
Track paused boolean set true on onMouseEnter / onFocusCapture, false on leave/blur. Gate the interval on !paused.
Multi-slide transform variant
<div
className="track"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{images.map((img) => (
<img key={img.src} src={img.src} alt={img.alt} loading="lazy" />
))}
</div>
Active image can use loading="eager" + fetchpriority="high" for LCP when carousel is hero.
Accessibility essentials
- Controls named; decorative chrome
aria-hidden - Live region announces slide changes (politely)
- Don’t rely on autoplay only to show content
- Pause control if autoplay exists (
aria-pressed) - Dot buttons not empty of accessible name
Performance notes
- Fixed aspect ratio → less CLS
- Lazy offscreen slides; decode async
- Prefer CSS
transformfor transitions over left animation - Preload
index+1image for snappier next
Footguns
- Empty
alton informative images - Autoplay ignoring reduced motion
- Modulo bugs with negative indices
- Interval stacking without cleanup
- Layout shift when images load without width/height or aspect-ratio
Interview out-loud answer
Carousel state is a single index with wrap or clamp. I’d reserve aspect ratio, wire prev/next/dots, and announce “slide i of n” in a live region. Autoplay is optional, paused on hover/focus and disabled under reduced motion. MVP is manual navigation; swipe and peek transitions if time remains.