Build an image carousel / slider
Track active index. Render slides in a flex row, transform: translateX(-index * 100%). Auto-advance with setInterval (pause on hover/focus). Lazy-load adjacent images. Keyboard arrows + ARIA region with live announcement.
A carousel is one of the most common machine-coding rounds because it touches state, transitions, lazy loading, accessibility, and touch interactions. The hard parts are looping, auto-advance pause, and screen-reader UX (carousels are notoriously bad for a11y if done naively).
Core: track active index, transform the track.
function Carousel({ images }: { images: { src: string; alt: string }[] }) {
const [index, setIndex] = useState(0);
const next = () => setIndex(i => (i + 1) % images.length);
const prev = () => setIndex(i => (i - 1 + images.length) % images.length);
return (
<section aria-roledescription="carousel" aria-label="Featured images" className="relative overflow-hidden">
<div
className="flex transition-transform duration-300"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{images.map((img, i) => (
<div key={i} role="group" aria-roledescription="slide" aria-label={`${i+1} of ${images.length}`} className="min-w-full">
<img src={img.src} alt={img.alt} loading={i === index || i === (index + 1) % images.length ? "eager" : "lazy"} />
</div>
))}
</div>
<button onClick={prev} aria-label="Previous slide">‹</button>
<button onClick={next} aria-label="Next slide">›</button>
<div role="tablist" aria-label="Slide selector">
{images.map((_, i) => (
<button key={i} role="tab" aria-selected={i === index} aria-label={`Slide ${i+1}`} onClick={() => setIndex(i)} />
))}
</div>
</section>
);
}Why translateX and not left? Transform is composited; left triggers layout. Smooth 60fps slide vs janky.
Auto-advance with pause-on-hover/focus.
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
const id = setInterval(next, 5000);
return () => clearInterval(id);
}, [paused, next]);
return (
<section
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
{/* … */}
</section>
);Auto-advance is hostile UX for keyboard and screen-reader users — pause on focus is the WCAG 2.2.2 requirement. Provide a Play/Pause button too.
Looping (infinite carousel). Two patterns:
- Modular index (above): wraps around; the transition flickers when going from slide N → 0 because the transform jumps from
-N*100%to0%. - Cloned slides: prepend the last slide before the first and append the first after the last; on transition end, snap to the real position (no transition). Smooth but state machine is fiddly.
Most modern carousels accept the visual "snap back" or use a fade transition between slides instead of horizontal slide.
Lazy load adjacent images. Don't use loading="lazy" for the current and next image — preload them so the next click is instant. Lazy-load further-away ones.
Keyboard model. Left/Right arrows move slides. Tab moves into the slide content (links, captions). Home/End optional.
Touch / drag. pointerdown → track deltaX; while dragging, set transform: translateX(calc(-index*100% + deltaX)). On pointerup: if |deltaX| > threshold (e.g., 50px), advance; otherwise snap back. Use pointer events for cross-device support.
ARIA roles & live regions.
aria-roledescription="carousel"on the section.aria-roledescription="slide"on each slide.aria-label="3 of 5"per slide so screen readers announce position.- A visually-hidden
aria-live="polite"announcing "Slide 3 of 5: Mountain landscape" on change. - Don't use
role="tablist"for the dot indicators if the carousel auto-advances — confuses SR users.role="group"of buttons is safer.
Common variants.
- Multi-slide visible (3 cards at a time):
min-width: calc(100% / 3)and translate by 1/3. - Vertical carousel:
flex-col+translateY. - Fade transition instead of slide: each slide stacked, opacity 0/1.
- Coverflow / 3D: heavy CSS — usually use Embla / Swiper.
Use a library in production. Embla Carousel (modern, lightweight, good a11y), Swiper (feature-heavy), Keen Slider. Roll your own only when you need a unique design or full control.
Anti-patterns. Don't auto-advance hero carousels on marketing pages — research consistently shows users skip them and they hurt LCP. A static hero with one strong image outperforms a carousel of three.
Code
Follow-up questions
- •Why translateX over left for the slide transition?
- •How do you make the loop seamless with cloned slides?
- •Why pause on focus, not just on hover?
- •When would you use a library like Embla over rolling your own?
Common mistakes
- •Animating left/width — layout per frame, janky.
- •Auto-advance without pause-on-focus — fails WCAG.
- •loading='lazy' on every image including the current — first paint shows broken images.
- •Using role=tablist for dot indicators on auto-advancing carousel — confusing SR semantics.
Performance considerations
- •Promote the track with will-change: transform during slides.
- •Lazy-load distant images; eager-load current + neighbors.
- •Use Intersection Observer to pause auto-advance when carousel scrolls off-screen.
Edge cases
- •Single image — hide controls.
- •Window resize during slide — recompute base translate.
- •Reduced motion (prefers-reduced-motion) — disable auto-advance and animations.
Real-world examples
- •Instagram stories, Amazon product image gallery, Apple homepage hero. Embla powers many of these.