ESC

Type to search the knowledge base.

Image Carousel

Machine-coding brief for an image carousel — slides, controls, keyboard, autoplay pause, a11y, and reduced motion.

intermediate4 min read
  • 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)

  1. Index state modulo length
  2. Aspect ratio box to reserve space
  3. Autoplay with cleanup + visibility pause
  4. MVP — index + buttons + dots; then autoplay + a11y live region
  5. 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 transform for transitions over left animation
  • Preload index+1 image for snappier next

Footguns

  1. Empty alt on informative images
  2. Autoplay ignoring reduced motion
  3. Modulo bugs with negative indices
  4. Interval stacking without cleanup
  5. 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.

Further reading