Back to Machine Coding
Machine Coding
medium
mid

How would you build an image carousel component?

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.

7 min read·~40 min to think through

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.

tsx
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.

tsx
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:

  1. Modular index (above): wraps around; the transition flickers when going from slide N → 0 because the transform jumps from -N*100% to 0%.
  2. 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

tsx
function useDragSlide(onSwipeLeft: () => void, onSwipeRight: () => void) {
  const startX = useRef<number | null>(null);
  return {
    onPointerDown: (e: React.PointerEvent) => { startX.current = e.clientX; },
    onPointerUp: (e: React.PointerEvent) => {
      if (startX.current === null) return;
      const dx = e.clientX - startX.current;
      if (Math.abs(dx) > 50) (dx > 0 ? onSwipeRight : onSwipeLeft)();
      startX.current = null;
    },
  };
}
Touch / drag with pointer events

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.

Senior engineer discussion

Senior signal: composited transform, pause-on-focus, ARIA carousel pattern, prefers-reduced-motion respect, and library awareness.

Related questions