Frontend System Design: Image Carousel

interviewMarch 28, 2026

The image carousel is a staple frontend system design question — asked at Amazon, Google, Dropbox, and Microsoft. It seems simple ("show images one at a time with prev/next buttons") but a complete answer covers layout, performance, touch support, autoplay, edge cases, and accessibility.

This article walks through a full RADIO-framework answer. If you're unfamiliar with RADIO, start here.

R — Requirements

Functional requirements:

  • Display a list of images, one at a time
  • Prev / Next buttons to navigate between images
  • Progress dots indicating current position; clickable to jump to any image
  • Infinite cycling — Next from the last image wraps to the first, and vice versa
  • Smooth horizontal transition animation between images

Non-functional requirements:

  • Works on desktop, tablet, and mobile (touch/swipe)
  • Interactive elements are large enough to tap on mobile (≥44×44px)
  • Images load lazily — don't download off-screen images upfront
  • Preload the next image to avoid a flash of blank
  • Accessible to screen reader and keyboard users

Extended requirements (if time allows):

  • Autoplay with configurable interval
  • Pause autoplay on hover / focus
  • Custom event callbacks (onNext, onPrev, onSelect)
  • Theming / custom styling

Out of scope:

  • Server-fetched images (images are provided as a config array)
  • Video support

A — Architecture

The carousel is a pure client-side component with no server interaction. It follows a unidirectional data flow: user actions trigger state changes, state changes drive renders.

┌─────────────────────────────────────────────────┐
│                <ImageCarousel>                  │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │           <CarouselTrack>                 │  │
│  │  [img0] [img1] [img2] [img3] ...          │  │
│  │  ←────── CSS flex, overflow hidden ──────→│  │
│  └───────────────────────────────────────────┘  │
│                                                 │
│  ┌──────────┐              ┌──────────────────┐ │
│  │ <PrevBtn> │              │    <NextBtn>     │ │
│  └──────────┘              └──────────────────┘ │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │           <ProgressDots>                  │  │
│  │   ● ○ ○ ○ ○  (filled = active)           │  │
│  └───────────────────────────────────────────┘  │
└─────────────────────────────────────────────────┘
         │
         │  useCarousel() hook
         │
┌────────┴────────────────────────────────────────┐
│  State: activeIndex, isPlaying                  │
│  Actions: prev(), next(), goTo(index), toggle() │
│  Effects: autoplay timer, keyboard listeners    │
└─────────────────────────────────────────────────┘

Components:

  • ImageCarousel — root, composes everything, owns ref to track element
  • CarouselTrack — horizontal flex container of all images
  • PrevBtn / NextBtn — navigation buttons
  • ProgressDots — dot indicators with click navigation
  • AutoplayToggle — optional play/pause button

Logic (hook):

  • useCarousel({ images, autoplayMs, loop }) — encapsulates all state and actions

A Flux-style reducer works well here since multiple action sources (button clicks, keyboard, timer, touch) all modify the same activeIndex:

type CarouselAction =
  | { type: 'NEXT' }
  | { type: 'PREV' }
  | { type: 'GO_TO'; index: number }
  | { type: 'TOGGLE_AUTOPLAY' };

function carouselReducer(state: CarouselState, action: CarouselAction): CarouselState {
  const { activeIndex, imageCount } = state;
  switch (action.type) {
    case 'NEXT':
      return { ...state, activeIndex: (activeIndex + 1) % imageCount };
    case 'PREV':
      return { ...state, activeIndex: (activeIndex - 1 + imageCount) % imageCount };
    case 'GO_TO':
      return { ...state, activeIndex: action.index };
    case 'TOGGLE_AUTOPLAY':
      return { ...state, isPlaying: !state.isPlaying };
  }
}

D — Data Model

Configuration (Props)

type CarouselImage = {
  src: string;
  alt: string;
  srcSet?: string;   // For responsive images
  sizes?: string;
};

interface ImageCarouselProps {
  images: CarouselImage[];
  transitionDuration?: number;  // ms, default 300
  autoplayMs?: number;          // 0 = disabled, default 0
  loop?: boolean;               // Default true
  height?: number;              // px
  width?: number;               // px
  // Callbacks
  onNext?: (index: number) => void;
  onPrev?: (index: number) => void;
  onSelect?: (index: number) => void;
}

Component State

type CarouselState = {
  activeIndex: number;
  imageCount: number;
  isPlaying: boolean;   // Autoplay running
};

I — Interface

Internal Actions

interface CarouselControls {
  prev: () => void;            // Prev button, keyboard ←, swipe right
  next: () => void;            // Next button, keyboard →, swipe left, autoplay timer
  goTo: (index: number) => void; // Progress dot click
  toggleAutoplay: () => void;  // Play/pause button
}

Component API Example (React)

<ImageCarousel
  images={[
    { src: '/img/paris.jpg', alt: 'Eiffel Tower at sunset' },
    { src: '/img/tokyo.jpg', alt: 'Tokyo skyline at night' },
    { src: '/img/nyc.jpg', alt: 'Manhattan skyline' },
  ]}
  transitionDuration={300}
  autoplayMs={4000}
  height={500}
  width={900}
  onSelect={(index) => analytics.track('carousel_slide', { index })}
/>

O — Optimisations

1. CSS Layout: Flex + Scroll

Image carousel CSS layout — overflow:hidden viewport with transform:translateX animation

The cleanest layout uses a flex container that's wider than its viewport, with overflow: hidden to clip unseen images:

.carousel-track {
  display: flex;
  overflow: hidden;         /* Clip images outside viewport */
  width: 900px;
  height: 500px;
}

.carousel-slide {
  flex: 0 0 900px;          /* Fixed size, no shrink/grow */
  height: 500px;
}

To navigate, shift the track using transform: translateX:

trackRef.current.style.transform = `translateX(-${activeIndex * slideWidth}px)`;
trackRef.current.style.transition = `transform ${transitionDuration}ms ease-in-out`;

This is faster than changing scrollLefttransform is composited on the GPU and never triggers layout.

2. Image Fit: object-fit

Images won't be the exact carousel dimensions. Use object-fit to handle this gracefully:

.carousel-slide img {
  width: 100%;
  height: 100%;
  object-fit: cover;    /* Fill the space, crop if needed */
  /* or: object-fit: contain;  Letterbox, no crop */
}

Allow this to be configurable — product teams will have opinions.

3. Lazy Loading + Smart Preloading

Lazy load all images except the first (don't waste bandwidth on images the user may never see):

<img
  src={image.src}
  alt={image.alt}
  loading={index === 0 ? 'eager' : 'lazy'}
/>

Preload the next image when the user shows intent to navigate (hover over button, focus on button, or just always preload activeIndex + 1):

function preloadImage(src: string) {
  const img = new Image();
  img.src = src;
}

// Preload next on hover
nextBtnRef.current?.addEventListener('mouseenter', () => {
  const nextIndex = (activeIndex + 1) % images.length;
  preloadImage(images[nextIndex].src);
});

Airbnb's approach: preload the 2nd image only after the user interacts with the carousel (hovers or tabs to it), then preload the next 3 after they view the 2nd. This balances bandwidth with UX.

4. CSS Scroll Snap (Alternative Approach)

An even simpler layout uses native scroll snapping — especially good for mobile swipe:

.carousel-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch; /* iOS momentum scrolling */
  scrollbar-width: none;              /* Hide scrollbar */
}

.carousel-slide {
  scroll-snap-align: start;
  flex: 0 0 100%;
}

Navigation:

function goTo(index: number) {
  trackRef.current?.scrollTo({ left: index * slideWidth, behavior: 'smooth' });
}

Pros: Mobile swipe works for free, zero JS for animation Cons: Harder to sync dot state with scroll position (need IntersectionObserver)

5. Touch / Swipe Support

For the transform-based approach, add touch gesture handling:

let touchStartX = 0;

trackRef.current?.addEventListener('touchstart', (e) => {
  touchStartX = e.touches[0].clientX;
}, { passive: true });

trackRef.current?.addEventListener('touchend', (e) => {
  const delta = touchStartX - e.changedTouches[0].clientX;
  if (Math.abs(delta) > 50) {   // 50px threshold
    delta > 0 ? next() : prev();
  }
});

6. Autoplay

useEffect(() => {
  if (!autoplayMs || !isPlaying) return;

  const timer = setInterval(() => {
    dispatch({ type: 'NEXT' });
  }, autoplayMs);

  return () => clearInterval(timer);
}, [autoplayMs, isPlaying, activeIndex]);

Pause on hover/focus — animating content is distracting when users are trying to interact:

<div
  onMouseEnter={() => setIsPlaying(false)}
  onMouseLeave={() => setIsPlaying(true)}
  onFocusCapture={() => setIsPlaying(false)}
  onBlurCapture={() => setIsPlaying(true)}
>

Also: stop autoplay on any manual navigation, restart the timer when the user stops interacting (reset the interval on each goTo call).

7. Keyboard Navigation

useEffect(() => {
  function handleKey(e: KeyboardEvent) {
    if (e.key === 'ArrowRight') next();
    if (e.key === 'ArrowLeft') prev();
    if (e.key === 'Home') goTo(0);
    if (e.key === 'End') goTo(images.length - 1);
  }
  window.addEventListener('keydown', handleKey);
  return () => window.removeEventListener('keydown', handleKey);
}, []);

8. Responsive Images

Serve appropriately sized images per device:

<img
  src={image.src}
  srcSet={`${image.src}?w=400 400w, ${image.src}?w=800 800w, ${image.src}?w=1200 1200w`}
  sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  alt={image.alt}
/>

Or use the <picture> element for art direction (different crops per breakpoint).

9. Accessibility (ARIA)

Image carousels are notoriously inaccessible. Do it right:

<section
  aria-label="Image carousel"
  aria-roledescription="carousel"
>
  {/* Announce current slide to screen readers */}
  <div aria-live="polite" aria-atomic="true" className="sr-only">
    Slide {activeIndex + 1} of {images.length}: {images[activeIndex].alt}
  </div>

  {/* Track */}
  <div role="group" aria-label={`Slide ${activeIndex + 1} of ${images.length}`}>
    {images.map((img, i) => (
      <div
        key={i}
        role="group"
        aria-roledescription="slide"
        aria-label={`Slide ${i + 1}: ${img.alt}`}
        aria-hidden={i !== activeIndex}
      >
        <img src={img.src} alt={img.alt} />
      </div>
    ))}
  </div>

  {/* Buttons */}
  <button aria-label="Previous slide" onClick={prev}></button>
  <button aria-label="Next slide" onClick={next}></button>

  {/* Dots */}
  <div role="tablist" aria-label="Choose slide">
    {images.map((_, i) => (
      <button
        key={i}
        role="tab"
        aria-selected={i === activeIndex}
        aria-label={`Go to slide ${i + 1}`}
        onClick={() => goTo(i)}
      />
    ))}
  </div>

  {/* Autoplay toggle */}
  {autoplayMs && (
    <button
      aria-label={isPlaying ? 'Pause autoplay' : 'Start autoplay'}
      onClick={toggleAutoplay}
    >
      {isPlaying ? '⏸' : '▶'}
    </button>
  )}
</section>

Key points:

  • aria-live="polite" announces slide changes to screen readers
  • aria-hidden hides off-screen slides from the accessibility tree
  • Buttons have descriptive aria-label (not just < and >)
  • Progress dots use role="tablist" / role="tab" with aria-selected
  • Autoplay toggle has dynamic aria-label reflecting current state

10. Prevent Layout Shift

Set explicit dimensions on the carousel wrapper to prevent layout shift (CLS) while images load:

.carousel-wrapper {
  width: 900px;
  height: 500px;
  background: #f0f0f0;   /* Placeholder colour */
  position: relative;
}

Use aspect-ratio for responsive layouts:

.carousel-wrapper {
  width: 100%;
  aspect-ratio: 16 / 9;
}

Summary

A complete image carousel system design covers:

  • Requirements: infinite cycling, animated transitions, progress dots, mobile swipe, lazy load, accessibility
  • Architecture: CarouselTrack + PrevBtn + NextBtn + ProgressDots, all driven by useCarousel hook with a Flux reducer for clean multi-source action handling
  • Data model: CarouselImage[] config, activeIndex + isPlaying state
  • Interface: prev(), next(), goTo(index), toggleAutoplay() actions; onNext/onPrev/onSelect callbacks
  • Optimisations: GPU-composited transform animation, object-fit: cover for image fitting, lazy loading + smart preloading, CSS scroll snap for mobile, touch gesture handling, pause-on-hover autoplay, keyboard arrows, responsive srcSet, full ARIA carousel pattern, explicit dimensions to prevent CLS

The Series: Frontend System Design Interview Questions