---
title: "System Design #14: Design an Image Carousel / Gallery"
description: ""
canonical_url: https://devprep.co/articles/system-design-image-carousel-gallery
md_url: https://devprep.co/articles/system-design-image-carousel-gallery.md
last_updated: 2026-02-10
access: public
---

# System Design #14: Design an Image Carousel / Gallery

## Hey folks, Rahul here 👋

Instagram stories, Airbnb listing photos, e-commerce product galleries — image carousels are one of the most common UI patterns, and one of the most frequently botched. Bad carousels jank on swipe, preload every image upfront (killing Core Web Vitals), break on touch devices, or trap keyboard focus.

Let's build one that's buttery smooth, memory-efficient, and accessible.

## R — Requirements

### Functional Requirements

- Horizontal slide navigation (prev/next buttons + swipe gestures)
- Dot indicators or thumbnail strip
- Auto-play with pause on hover/focus
- Lightbox mode (fullscreen with zoom/pan)
- Lazy loading of off-screen images
- Loop / no-loop modes
- Support for mixed media (images + videos)

### Non-Functional Requirements

- **Performance**: 60fps slide transitions, no layout shift (CLS = 0)
- **Loading**: Only load current + adjacent slides
- **Accessibility**: ARIA live region, roledescription, keyboard nav
- **Touch**: Smooth swipe with momentum, snap to slide
- **Memory**: Handle 1000+ images without OOM

## A — Architecture

### Rendering Approaches

#### Approach 1: CSS Scroll Snap (Modern, Recommended) ✅

```typescript
// Let the browser handle scroll physics — it's smoother than JS
function CSSSnapCarousel({ items }: { items: CarouselItem[] }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [activeIndex, setActiveIndex] = useState(0);

  // Track which slide is snapped
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
            const index = Number(entry.target.getAttribute('data-index'));
            setActiveIndex(index);
          }
        });
      },
      { root: container, threshold: 0.5 }
    );

    container.querySelectorAll('[data-index]').forEach(el => observer.observe(el));
    return () => observer.disconnect();
  }, [items.length]);

  return (
    <div
      ref={containerRef}
      className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
      style={{ scrollBehavior: 'smooth' }}
      role="region"
      aria-roledescription="carousel"
      aria-label="Image gallery"
    >
      {items.map((item, i) => (
        <div
          key={item.id}
          data-index={i}
          className="flex-none w-full snap-center"
          role="group"
          aria-roledescription="slide"
          aria-label={`Slide ${i + 1} of ${items.length}`}
        >
          <CarouselSlide item={item} isActive={i === activeIndex} />
        </div>
      ))}
    </div>
  );
}
```

#### Approach 2: Transform-Based (More Control)

```typescript
// For custom physics or complex animations
function TransformCarousel({ items }: { items: CarouselItem[] }) {
  const [offset, setOffset] = useState(0);
  const [isDragging, setIsDragging] = useState(false);
  const dragStart = useRef(0);
  const dragOffset = useRef(0);

  const goTo = (index: number) => {
    setOffset(-index * 100); // Percentage-based
  };

  return (
    <div className="overflow-hidden">
      <div
        className={cn("flex", !isDragging && "transition-transform duration-300")}
        style={{ transform: `translateX(${offset}%)` }}
        onPointerDown={handleDragStart}
        onPointerMove={handleDragMove}
        onPointerUp={handleDragEnd}
      >
        {items.map((item, i) => (
          <div key={item.id} className="flex-none w-full">
            <CarouselSlide item={item} />
          </div>
        ))}
      </div>
    </div>
  );
}
```

### Component Tree

```text
ImageGallery
├── CarouselContainer           // Main slide area
│   ├── CarouselSlide[]         // Individual slides
│   │   └── LazyImage           // Intersection-based loading
│   ├── PrevButton / NextButton // Arrow navigation
│   └── SwipeHandler            // Touch gesture detection
├── Indicators                  // Dots or thumbnail strip
│   └── ThumbnailStrip          // Scrollable thumbnails
├── Lightbox (Portal)           // Fullscreen overlay
│   ├── ZoomableImage           // Pinch-zoom + pan
│   ├── LightboxControls
│   └── LightboxThumbnails
└── AutoplayController          // Timer + pause logic
```

## D — Data Model

```typescript
interface CarouselItem {
  id: string;
  type: 'image' | 'video';
  src: string;
  srcSet?: string;              // Responsive sources
  thumbnail?: string;           // Low-res placeholder
  blurHash?: string;            // BlurHash for skeleton
  alt: string;
  width: number;
  height: number;               // For aspect ratio (prevents CLS)
  caption?: string;
}

interface CarouselState {
  activeIndex: number;
  totalSlides: number;
  isAutoPlaying: boolean;
  isLightboxOpen: boolean;
  lightboxIndex: number;
  loadedIndices: Set<number>;   // Track which images are loaded
  direction: 'left' | 'right';  // For animation direction
}
```

### Lazy Loading Strategy

```typescript
// Only load: current slide, previous slide, next slide
function useAdjacentPreload(activeIndex: number, total: number) {
  const [loaded, setLoaded] = useState<Set<number>>(new Set([0]));

  useEffect(() => {
    const toLoad = new Set(loaded);
    toLoad.add(activeIndex);
    if (activeIndex > 0) toLoad.add(activeIndex - 1);
    if (activeIndex < total - 1) toLoad.add(activeIndex + 1);
    setLoaded(toLoad);
  }, [activeIndex, total]);

  return loaded;
}

function LazyImage({ item, shouldLoad }: { item: CarouselItem; shouldLoad: boolean }) {
  if (!shouldLoad) {
    // Show blurHash placeholder or aspect-ratio skeleton
    return (
      <div
        className="bg-muted animate-pulse"
        style={{ aspectRatio: `${item.width}/${item.height}` }}
      />
    );
  }

  return (
    <img
      src={item.src}
      srcSet={item.srcSet}
      alt={item.alt}
      loading="lazy"
      decoding="async"
      className="w-full h-full object-cover"
      style={{ aspectRatio: `${item.width}/${item.height}` }}
    />
  );
}
```

## I — Interface Definition

### Touch Gesture Handler

```typescript
function useSwipeGesture(onSwipe: (dir: 'left' | 'right') => void) {
  const startX = useRef(0);
  const startY = useRef(0);
  const isDragging = useRef(false);

  const handlers = {
    onTouchStart: (e: React.TouchEvent) => {
      startX.current = e.touches[0].clientX;
      startY.current = e.touches[0].clientY;
      isDragging.current = true;
    },

    onTouchEnd: (e: React.TouchEvent) => {
      if (!isDragging.current) return;
      isDragging.current = false;

      const endX = e.changedTouches[0].clientX;
      const endY = e.changedTouches[0].clientY;
      const deltaX = endX - startX.current;
      const deltaY = endY - startY.current;

      // Only trigger if horizontal swipe is dominant
      if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
        onSwipe(deltaX > 0 ? 'right' : 'left');
      }
    },
  };

  return handlers;
}
```

### Lightbox with Zoom/Pan

```typescript
function ZoomableImage({ src, alt }: { src: string; alt: string }) {
  const [scale, setScale] = useState(1);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const isDragging = useRef(false);
  const lastPos = useRef({ x: 0, y: 0 });

  const handleWheel = (e: React.WheelEvent) => {
    e.preventDefault();
    const newScale = Math.max(1, Math.min(5, scale - e.deltaY * 0.01));
    setScale(newScale);
    if (newScale === 1) setPosition({ x: 0, y: 0 }); // Reset pan
  };

  const handleDoubleClick = () => {
    if (scale > 1) {
      setScale(1);
      setPosition({ x: 0, y: 0 });
    } else {
      setScale(2.5);
    }
  };

  return (
    <div
      className="overflow-hidden cursor-grab active:cursor-grabbing"
      onWheel={handleWheel}
      onDoubleClick={handleDoubleClick}
      onPointerDown={startDrag}
      onPointerMove={drag}
      onPointerUp={endDrag}
    >
      <img
        src={src}
        alt={alt}
        className="max-h-[90vh] object-contain transition-transform duration-200"
        style={{
          transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
        }}
        draggable={false}
      />
    </div>
  );
}
```

## O — Optimizations

### 1. Prevent Cumulative Layout Shift

```typescript
// ALWAYS set aspect ratio to prevent CLS
function CarouselSlide({ item }: { item: CarouselItem }) {
  return (
    <div
      className="relative w-full"
      style={{ aspectRatio: `${item.width}/${item.height}` }}
    >
      <img
        src={item.src}
        alt={item.alt}
        className="absolute inset-0 w-full h-full object-cover"
      />
    </div>
  );
}
```

### 2. Autoplay with Accessibility

```typescript
function useAutoplay(goNext: () => void, interval: number = 5000) {
  const [isPlaying, setIsPlaying] = useState(true);
  const timerRef = useRef<ReturnType<typeof setInterval>>();

  useEffect(() => {
    if (!isPlaying) return;
    timerRef.current = setInterval(goNext, interval);
    return () => clearInterval(timerRef.current);
  }, [isPlaying, interval]);

  // Pause on hover, focus, or reduced-motion preference
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

  useEffect(() => {
    if (prefersReducedMotion) setIsPlaying(false);
  }, [prefersReducedMotion]);

  return {
    isPlaying,
    pause: () => setIsPlaying(false),
    play: () => setIsPlaying(true),
    toggle: () => setIsPlaying(p => !p),
  };
}
```

### 3. Virtualized Gallery for 1000+ Images

```typescript
// For massive galleries, only render slides near the viewport
function VirtualizedCarousel({ items }: { items: CarouselItem[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  // Only render 5 slides: [active-2, active-1, active, active+1, active+2]
  const windowSize = 5;
  const startIndex = Math.max(0, activeIndex - 2);
  const endIndex = Math.min(items.length - 1, activeIndex + 2);

  const visibleSlides = items.slice(startIndex, endIndex + 1).map((item, i) => ({
    ...item,
    virtualIndex: startIndex + i,
  }));

  return (
    <div className="relative overflow-hidden">
      {visibleSlides.map(slide => (
        <div
          key={slide.id}
          className="absolute inset-0 transition-transform duration-300"
          style={{
            transform: `translateX(${(slide.virtualIndex - activeIndex) * 100}%)`,
          }}
        >
          <CarouselSlide item={slide} />
        </div>
      ))}
    </div>
  );
}
```

## Production Gotchas Rahul Has Debugged 🔥

1. **Scroll Snap + Dynamic Content**: If slide dimensions change after snap (image loads, text expands), the snap position drifts. Set explicit dimensions on containers, not content.
2. **Swipe vs. Scroll**: Horizontal carousels can hijack vertical page scroll. Only capture the gesture if the horizontal delta is significantly larger than the vertical delta (2:1 ratio).
3. **Auto-play Accessibility**: WCAG 2.2.2 requires a pause mechanism for any auto-moving content. Always provide a visible pause button and respect `prefers-reduced-motion`.
4. **Image Decode Jank**: Large images decoded on the main thread cause frame drops during transitions. Use `img.decode()` to decode off-thread before displaying, or use `decoding="async"`.
5. **Object URL Leaks in Lightbox**: If you create blob URLs for zoom functionality, always `URL.revokeObjectURL()` on cleanup.

Next: **#15: Design a Spreadsheet / Data Grid** — virtual scrolling in 2D, cell selection state, formula parsing, and copy-paste interop. 📊

---

Public pages are mirrored here in full. Premium pages are listed with their summary only — the gated body stays behind server-side entitlement checks.
