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.
Table of Contents
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.
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.
┌──────────────────────────────────────────┐ │ │ │ ◀ ┌──────────────────────────────┐ ▶ │ │ │ │ │ │ │ Active Image │ │ ← Viewport (overflow: hidden) │ │ │ │ │ └──────────────────────────────┘ │ │ │ │ ● ● ○ ● ● │ ← Dot indicators └──────────────────────────────────────────┘ Behind the viewport: ┌────────┬────────┬────────┬────────┬────────┐ │ Slide 1│ Slide 2│ Slide 3│ Slide 4│ Slide 5│ ← Slides track └────────┴────────┴────────┴────────┴────────┘ ← translateX(-200%) ───────────────── (showing slide 3)
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.
// 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 Property | Purpose |
|---|---|
| overflow: hidden | Hides slides that aren't in the viewport |
| display: flex | Lays 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-transform | Animates 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.
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.
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.
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.
Track hover state
Use a state variable `isPaused` or a ref. Set it to true on mouseenter, false on mouseleave.
Set up the interval
In useEffect, create a setInterval that calls goNext() every 3 seconds. Only run it when isPaused is false.
Clean up on every render
Return a cleanup function that calls clearInterval. This runs when isPaused changes or the component unmounts.
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.
Keyboard Navigation
Keyboard support requires two things: making the carousel focusable and listening for key events.
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>
| Key | Action |
|---|---|
| ArrowLeft | Go to previous slide (wraps around) |
| ArrowRight | Go 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.
Full Implementation
Here's the complete carousel with all features. Study how the state, effects, and event handlers compose together.
"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> ); }
GPU-accelerated transitions
Using transform: translateX instead of left/margin. Runs on the compositor thread — no layout reflow, smooth 60fps.
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.
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.
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.
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! 🚀