DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #14: Design an Image Carousel / Gallery
XLinkedInReddit
MediumFrontend Engineering

System Design #14: Design an Image Carousel / Gallery

D
DevPrep Team
6 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Rendering Approaches
  • Component Tree
  • D โ€” Data Model
  • Lazy Loading Strategy
  • I โ€” Interface Definition
  • Touch Gesture Handler
  • Lightbox with Zoom/Pan
  • O โ€” Optimizations
  • 1. Prevent Cumulative Layout Shift
  • 2. Autoplay with Accessibility
  • 3. Virtualized Gallery for 1000+ Images
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

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) โœ…

// 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)

// 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

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

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

// 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

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

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

// 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

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

// 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. ๐Ÿ“Š

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Rendering Approaches
  • Component Tree
  • D โ€” Data Model
  • Lazy Loading Strategy
  • I โ€” Interface Definition
  • Touch Gesture Handler
  • Lightbox with Zoom/Pan
  • O โ€” Optimizations
  • 1. Prevent Cumulative Layout Shift
  • 2. Autoplay with Accessibility
  • 3. Virtualized Gallery for 1000+ Images
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.