ReactCSS TransformssetIntervalKeyboard EventsAccessibility

Build an Image Carousel

Learn how to build an image carousel from scratch with smooth CSS transitions, auto-play, pause on hover, keyboard navigation, and dot indicators. A classic frontend interview question that tests DOM manipulation, timers, and UI polish.

25 min read8 sections
01

Problem Statement

Build an image carousel that displays one image at a time with smooth transitions between slides. This is a common component in e-commerce sites, portfolios, and landing pages.

  • Display one image at a time from a list of slides
  • Previous / Next buttons to navigate between slides
  • Dot indicators showing the current slide, clickable to jump
  • Smooth CSS transition when changing slides
  • Wrap around: last → first and first → last
  • Auto-play that advances every 3 seconds
  • Pause auto-play when the user hovers over the carousel
  • Keyboard navigation with ArrowLeft and ArrowRight

Why this question?

Carousels test a unique mix of skills: CSS transforms for animation, setInterval for timers with proper cleanup, event handling for hover and keyboard, and state management for the active index. It's visual, interactive, and has many edge cases.

02

Component Anatomy

Break the carousel into its visual parts before writing any code:

🖼️

Viewport

The visible area with overflow: hidden. Only one slide is visible at a time. All slides sit in a horizontal track behind it.

🎞️

Slides Track

A flex container holding all slides side by side. Moves left/right via CSS translateX to show the active slide.

◀▶

Prev / Next Buttons

Absolutely positioned on the left and right edges. Clicking them changes the current index by ±1 with wrap-around.

●●●

Dot Indicators

One dot per slide, positioned at the bottom center. The active dot is highlighted. Clicking a dot jumps to that slide.

Visual structuretext
┌──────────────────────────────────────────┐
│                                          │
│  ◀  ┌──────────────────────────────┐  ▶  │
│     │                              │     │
│     │         Active Image         │     │  ← Viewport (overflow: hidden)
│     │                              │     │
│     └──────────────────────────────┘     │
│                                          │
│              ● ● ○ ● ●                   │  ← Dot indicators
└──────────────────────────────────────────┘

Behind the viewport:
┌────────┬────────┬────────┬────────┬────────┐
Slide 1Slide 2Slide 3Slide 4Slide 5│  ← Slides track
└────────┴────────┴────────┴────────┴────────┘
translateX(-200%) ─────────────────   (showing slide 3)
03

CSS Transition Technique

The core trick is a flex container where each slide takes 100% width, and you shift the entire track with translateX. CSS handles the animation.

The CSS approachtypescript
// Viewport — clips everything outside
<div className="relative overflow-hidden">

  {/* Track — holds all slides in a row */}
  <div
    className="flex transition-transform duration-500 ease-in-out"
    style={{
      transform: `translateX(-${currentIndex * 100}%)`
    }}
  >
    {slides.map((slide) => (
      <div key={slide.id} className="w-full shrink-0">
        <img
          src={slide.url}
          alt={slide.alt}
          className="w-full aspect-video object-cover"
        />
      </div>
    ))}
  </div>

</div>
CSS PropertyPurpose
overflow: hiddenHides slides that aren't in the viewport
display: flexLays slides out horizontally in a row
flex-shrink: 0 + width: 100%Each slide takes exactly the full viewport width
transform: translateX(-N%)Shifts the track to show the Nth slide
transition-transformAnimates the shift smoothly over 500ms

Why translateX over left/margin?

transform: translateX is GPU-accelerated and doesn't trigger layout reflow. Animatingleft ormargin-left causes the browser to recalculate layout on every frame — much slower. Always mention this in interviews.

04

Navigation & Wrap-Around

Navigation is three functions: go to next, go to previous, and jump to a specific index. The wrap-around logic uses modulo arithmetic.

Navigation functionstypescript
const goNext = () => {
  setCurrentIndex((prev) => (prev + 1) % SLIDES.length);
  // Slide 4 (last): (4 + 1) % 5 = 0 → wraps to first
};

const goPrev = () => {
  setCurrentIndex((prev) =>
    (prev - 1 + SLIDES.length) % SLIDES.length
  );
  // Slide 0 (first): (0 - 1 + 5) % 5 = 4 → wraps to last
};

const goTo = (index: number) => {
  setCurrentIndex(index);
};

The +SLIDES.length trick

In JavaScript, -1 % 5 returns-1, not4. AddingSLIDES.length before the modulo ensures the result is always positive. This is a common gotcha interviewers watch for.

05

Auto-Play & Pause on Hover

Auto-play uses setInterval inside a useEffect. The tricky part is pausing when the user hovers and cleaning up properly.

1

Track hover state

Use a state variable `isPaused` or a ref. Set it to true on mouseenter, false on mouseleave.

2

Set up the interval

In useEffect, create a setInterval that calls goNext() every 3 seconds. Only run it when isPaused is false.

3

Clean up on every render

Return a cleanup function that calls clearInterval. This runs when isPaused changes or the component unmounts.

Auto-play with pause on hovertypescript
const [isPaused, setIsPaused] = useState(false);

useEffect(() => {
  if (isPaused) return; // Don't start interval when paused

  const interval = setInterval(() => {
    setCurrentIndex((prev) => (prev + 1) % SLIDES.length);
  }, 3000);

  // Cleanup: clear interval when isPaused changes or unmount
  return () => clearInterval(interval);
}, [isPaused]);

// On the carousel wrapper div:
<div
  onMouseEnter={() => setIsPaused(true)}
  onMouseLeave={() => setIsPaused(false)}
>
  {/* ... carousel content */}
</div>

Why not useRef for the interval?

You can store the interval ID in a ref, but it's more code for no benefit here. The useEffect cleanup pattern is cleaner — React handles clearing and restarting automatically when isPaused changes. Mention both approaches in interviews to show flexibility.

06

Keyboard Navigation

Keyboard support requires two things: making the carousel focusable and listening for key events.

Keyboard navigationtypescript
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === "ArrowLeft") {
    e.preventDefault();
    goPrev();
  } else if (e.key === "ArrowRight") {
    e.preventDefault();
    goNext();
  }
};

// The carousel wrapper needs tabIndex to receive focus:
<div
  tabIndex={0}
  onKeyDown={handleKeyDown}
  className="outline-none focus:ring-2 focus:ring-gray-900 rounded-xl"
>
  {/* ... carousel content */}
</div>
KeyAction
ArrowLeftGo to previous slide (wraps around)
ArrowRightGo to next slide (wraps around)

tabIndex={0} is essential

A div is not focusable by default. Without tabIndex={0}, the onKeyDown handler never fires because the element can't receive keyboard focus. This is a common mistake in interviews.

07

Full Implementation

Here's the complete carousel with all features. Study how the state, effects, and event handlers compose together.

image-carousel/page.tsxtypescript
"use client";

import { useState, useEffect } from "react";

interface Slide {
  id: number;
  url: string;
  alt: string;
}

const SLIDES: Slide[] = [
  { id: 1, url: "https://picsum.photos/seed/c1/800/450", alt: "Mountain landscape" },
  { id: 2, url: "https://picsum.photos/seed/c2/800/450", alt: "Ocean sunset" },
  { id: 3, url: "https://picsum.photos/seed/c3/800/450", alt: "Forest trail" },
  { id: 4, url: "https://picsum.photos/seed/c4/800/450", alt: "City skyline" },
  { id: 5, url: "https://picsum.photos/seed/c5/800/450", alt: "Desert dunes" },
];

export default function ImageCarouselPage() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isPaused, setIsPaused] = useState(false);

  // Auto-play
  useEffect(() => {
    if (isPaused) return;

    const interval = setInterval(() => {
      setCurrentIndex((prev) => (prev + 1) % SLIDES.length);
    }, 3000);

    return () => clearInterval(interval);
  }, [isPaused]);

  const goNext = () =>
    setCurrentIndex((prev) => (prev + 1) % SLIDES.length);

  const goPrev = () =>
    setCurrentIndex(
      (prev) => (prev - 1 + SLIDES.length) % SLIDES.length
    );

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowLeft") {
      e.preventDefault();
      goPrev();
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      goNext();
    }
  };

  return (
    <div
      className="relative overflow-hidden rounded-xl"
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onMouseEnter={() => setIsPaused(true)}
      onMouseLeave={() => setIsPaused(false)}
      role="region"
      aria-label="Image carousel"
      aria-roledescription="carousel"
    >
      {/* Slides track */}
      <div
        className="flex transition-transform duration-500 ease-in-out"
        style={{
          transform: `translateX(-${currentIndex * 100}%)`,
        }}
      >
        {SLIDES.map((slide) => (
          <div
            key={slide.id}
            className="w-full shrink-0"
            role="group"
            aria-roledescription="slide"
            aria-label={`Slide ${slide.id} of ${SLIDES.length}`}
          >
            <img
              src={slide.url}
              alt={slide.alt}
              className="w-full aspect-video object-cover"
            />
          </div>
        ))}
      </div>

      {/* Prev / Next buttons */}
      <button onClick={goPrev} aria-label="Previous slide">

      </button>
      <button onClick={goNext} aria-label="Next slide">

      </button>

      {/* Dot indicators */}
      <div>
        {SLIDES.map((slide, i) => (
          <button
            key={slide.id}
            onClick={() => setCurrentIndex(i)}
            aria-label={`Go to slide ${i + 1}`}
            aria-current={i === currentIndex ? "true" : undefined}
          />
        ))}
      </div>
    </div>
  );
}
✓ Done

GPU-accelerated transitions

Using transform: translateX instead of left/margin. Runs on the compositor thread — no layout reflow, smooth 60fps.

✓ Done

Proper interval cleanup

useEffect returns a cleanup function that clears the interval. Prevents memory leaks and stale timers when isPaused changes or the component unmounts.

→ Could add

Image lazy loading

Add loading='lazy' to images that aren't the current or adjacent slides. Reduces initial page load for carousels with many high-res images.

→ Could add

Touch / swipe support

Track touchstart and touchend positions. If the horizontal delta exceeds a threshold, navigate to the next or previous slide. Essential for mobile.

08

Common Interview Follow-up Questions

After building the carousel, interviewers dig into edge cases and enhancements:

Q:How would you add touch/swipe support for mobile?

A: Listen for touchstart (record startX) and touchend (record endX). Calculate the delta. If it exceeds a threshold (e.g., 50px), call goNext (swipe left) or goPrev (swipe right). Use touchmove with preventDefault to prevent page scrolling during the swipe.

Q:How would you make an infinite/seamless loop without the 'snap back' effect?

A: Clone the first slide at the end and the last slide at the beginning. When the transition to the clone ends (transitionend event), instantly jump to the real slide by disabling the transition, updating the index, then re-enabling the transition on the next frame.

Q:What if there are 50 high-resolution images?

A: Lazy load images: only load the current slide and its immediate neighbors. Use loading='lazy' on img tags or IntersectionObserver. Consider using srcset for responsive images. Preload the next slide during the auto-play interval.

Q:How would you handle dynamic slide content (not just images)?

A: Make each slide a children slot or render prop instead of hardcoding img tags. The carousel logic (index, transitions, navigation) stays the same — only the slide content changes. This is how libraries like Embla Carousel work.

Q:Why not use CSS scroll-snap instead of translateX?

A: CSS scroll-snap is simpler for basic carousels and gives native momentum scrolling on mobile. But it offers less control over auto-play, programmatic navigation, and transition timing. For interview purposes, the translateX approach shows deeper understanding.

Q:How would you animate the dots or add a progress bar?

A: For dots: use CSS transition on width/opacity to animate the active dot. For a progress bar: use a div with width transitioning from 0% to 100% over the auto-play interval (3s). Reset it on slide change. Use CSS animation with animation-play-state for pause.

Q:How do you prevent layout shift when images load?

A: Set a fixed aspect ratio on the slide container with aspect-video (16:9) or a padding-bottom hack. This reserves space before the image loads. Add a background color or skeleton placeholder. Use width and height attributes on the img tag.

Q:What about accessibility for screen readers?

A: Add role='region' with aria-roledescription='carousel' on the wrapper. Each slide gets role='group' with aria-roledescription='slide'. Use aria-label for prev/next buttons and dots. Add aria-live='polite' to announce slide changes. Pause auto-play when the carousel has focus.

Ready to build it yourself?

We've set up the basic carousel with slides and navigation. Add auto-play, pause on hover, and keyboard support from scratch.

Built for developers, by developers. Happy coding! 🚀