EmpireUI
Get Pro
← Blog9 min read#product gallery#react#zoom

Product Image Gallery in React: Zoom, Thumbnails, Touch Swipe

Build a production-ready product image gallery in React with pinch-to-zoom, thumbnail strip, and touch swipe — no heavy library required.

React product image gallery with zoom and thumbnail strip on desktop

Why Most Gallery Libraries Are Overkill

You've been there. You need a product image gallery, you npm install some-react-lightbox-whatever, and suddenly you've got 47KB of JavaScript to show three product photos. That's not a trade-off — it's a mistake.

The truth is a fully-featured product gallery in 2026 doesn't need a library at all for the core features. Zoom? CSS transform: scale() and a wheel event listener. Thumbnails? A flex row with overflow-x: auto. Swipe? onPointerDown + onPointerUp delta math. You're maybe 150 lines of code from something that outperforms most npm packages.

That said, some libraries genuinely help — especially react-medium-image-zoom for accessible zoom, or swiper if you need complex carousels. But for a focused product gallery, rolling your own gives you full CSS control, no hydration mismatches in Next.js, and zero bundle bloat. Let's build the real thing.

Quick aside: this guide assumes React 18+ and TypeScript. The patterns work fine in plain JS — just drop the type annotations.

Component Architecture: What You're Actually Building

A product gallery has three distinct concerns and you want to keep them separated. The main image viewer handles zoom and the large display. The thumbnail strip manages which image is active and lets users pick. Touch/pointer handling is a third concern that sits across both.

Here's the component tree we'll build:

<ProductGallery images={images}>
  <MainViewer />       // zoom + pan
  <ThumbnailStrip />   // horizontal scroll, active state
</ProductGallery>

The parent ProductGallery owns state: activeIndex, zoomLevel, and panOffset. Both children read from context. This avoids prop drilling and makes each piece independently testable. In practice, lifting state here is the right call — the thumbnail and viewer are always in sync by definition.

One more thing — don't reach for useReducer yet. Three related state values is still fine with useState. Save the reducer pattern for when you add variant switching, video support, or 360° rotation.

Building the Zoomable Main Image

The zoom mechanism uses CSS transforms. No canvas, no WebGL. You get hardware-accelerated rendering for free and the browser handles subpixel antialiasing automatically.

import { useRef, useState, WheelEvent } from 'react'

type Point = { x: number; y: number }

export function MainViewer({ src, alt }: { src: string; alt: string }) {
  const [zoom, setZoom] = useState(1)
  const [pan, setPan] = useState<Point>({ x: 0, y: 0 })
  const isDragging = useRef(false)
  const lastPos = useRef<Point>({ x: 0, y: 0 })

  function handleWheel(e: WheelEvent<HTMLDivElement>) {
    e.preventDefault()
    setZoom(z => Math.min(4, Math.max(1, z - e.deltaY * 0.002)))
    if (zoom <= 1) setPan({ x: 0, y: 0 })
  }

  function handlePointerDown(e: React.PointerEvent) {
    if (zoom <= 1) return
    isDragging.current = true
    lastPos.current = { x: e.clientX, y: e.clientY }
  }

  function handlePointerMove(e: React.PointerEvent) {
    if (!isDragging.current) return
    const dx = e.clientX - lastPos.current.x
    const dy = e.clientY - lastPos.current.y
    lastPos.current = { x: e.clientX, y: e.clientY }
    setPan(p => ({ x: p.x + dx, y: p.y + dy }))
  }

  return (
    <div
      className="overflow-hidden relative w-full aspect-square rounded-xl"
      onWheel={handleWheel}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={() => (isDragging.current = false)}
    >
      <img
        src={src}
        alt={alt}
        draggable={false}
        className="w-full h-full object-cover select-none transition-transform duration-100"
        style={{
          transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
          cursor: zoom > 1 ? 'grab' : 'zoom-in',
        }}
      />
    </div>
  )
}

Notice Math.min(4, Math.max(1, ...)) — that clamps zoom between 1× and 4×. Going above 4× on a standard 800px product image starts showing pixel artifacts at 96dpi. The 0.002 multiplier on deltaY is calibrated for typical trackpad delta values; a mouse wheel gives deltas around 100 so the step per notch is about 0.2, which feels responsive without being jumpy.

Honestly, the transition-transform duration-100 is a small touch but it matters enormously on desktop. Without it, mouse-wheel zoom feels mechanical. With it, you get smooth interpolation at 100ms which is fast enough to not feel laggy but slow enough to read as intentional.

The translate(${pan.x / zoom}px, ${pan.y / zoom}px) division is the part people usually get wrong. Because the translate happens inside the scale transform, you need to compensate — otherwise panning feels 4× too fast at 4× zoom.

Touch Swipe Between Images

Pointer events handle both mouse and touch in modern browsers, but swipe needs a horizontal delta threshold. Under 40px of horizontal movement and you ignore it — that covers accidental drags while zooming.

function useSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) {
  const startX = useRef<number | null>(null)

  return {
    onPointerDown: (e: React.PointerEvent) => {
      startX.current = e.clientX
    },
    onPointerUp: (e: React.PointerEvent) => {
      if (startX.current === null) return
      const delta = e.clientX - startX.current
      if (Math.abs(delta) < 40) return  // ignore micro-swipes
      if (delta < 0) onSwipeLeft()
      else onSwipeRight()
      startX.current = null
    },
  }
}

Plug this into the gallery wrapper, not the zoom layer. You don't want swipe competing with pan when the user is zoomed in — so gate swipe behind zoom === 1:

const swipeHandlers = useSwipe(
  () => zoom === 1 && goNext(),
  () => zoom === 1 && goPrev(),
)

Worth noting: touch-action: pan-y on the container is important for mobile. Without it, horizontal swipes trigger browser back-navigation on some Android WebViews. Set it via the style prop or a utility class — if you're on Tailwind, there's touch-pan-y.

The 40px threshold comes from Apple's own HIG. Below that, users can't reliably distinguish intent from scroll. You could go up to 60px on wide screens if you find false positives, but 40px is a solid default that works from 320px viewports up.

Thumbnail Strip with Keyboard Navigation

The thumbnail strip is a horizontal scrollable flex row. Active thumbnail gets a 2px ring — use ring-2 ring-offset-2 in Tailwind, or outline: 2px solid with outline-offset: 2px in plain CSS. Don't use border for the active state because it shifts layout. Rings are positioned outside the element.

export function ThumbnailStrip({
  images,
  active,
  onChange,
}: {
  images: string[]
  active: number
  onChange: (i: number) => void
}) {
  const stripRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = stripRef.current?.children[active] as HTMLElement
    el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
  }, [active])

  return (
    <div
      ref={stripRef}
      role="listbox"
      aria-label="Product images"
      className="flex gap-2 overflow-x-auto py-2 scrollbar-hide"
    >
      {images.map((src, i) => (
        <button
          key={src}
          role="option"
          aria-selected={i === active}
          onClick={() => onChange(i)}
          className={`shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 ${
            i === active ? 'border-indigo-500' : 'border-transparent'
          }`}
        >
          <img src={src} alt={`View ${i + 1}`} className="w-full h-full object-cover" />
        </button>
      ))}
    </div>
  )
}

The scrollIntoView call on active change handles the case where you have 8+ product images and the user navigates via keyboard arrows. Without it, thumbnails scroll off screen and users lose context. Set inline: 'center' so the active thumb stays in the middle of the strip rather than snapping to the edge.

Keyboard nav is two lines. Add onKeyDown to the strip container and handle ArrowLeft/ArrowRight. If you're using the role="listbox" / role="option" pattern above, screen readers already announce navigation semantically. Look, accessibility doesn't have to be a separate sprint — building it right the first time takes maybe 20 minutes.

For the visual style, these thumbnails pair well with glassmorphism components if your product page uses a frosted background. A backdrop-filter: blur(8px) on the strip container with a semi-transparent background keeps the thumbnails readable over busy product photography.

Pinch-to-Zoom on Mobile

Pointer events don't expose pinch distance natively. You need TouchEvent for that — specifically e.touches[0] and e.touches[1] to calculate the distance between two fingers.

function getPinchDistance(e: TouchEvent): number {
  const [t1, t2] = [e.touches[0], e.touches[1]]
  return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
}

const pinchStart = useRef<number | null>(null)
const zoomStart = useRef(1)

function onTouchStart(e: React.TouchEvent) {
  if (e.touches.length === 2) {
    pinchStart.current = getPinchDistance(e.nativeEvent)
    zoomStart.current = zoom
  }
}

function onTouchMove(e: React.TouchEvent) {
  if (e.touches.length !== 2 || pinchStart.current === null) return
  e.preventDefault() // stop page zoom
  const ratio = getPinchDistance(e.nativeEvent) / pinchStart.current
  setZoom(Math.min(4, Math.max(1, zoomStart.current * ratio)))
}

The e.preventDefault() inside onTouchMove is the key call here. Without it, iOS Safari interprets the two-finger gesture as page zoom instead of component zoom. Note that passive event listeners (React's default) will log a warning if you call preventDefault — you need to attach the listener manually with { passive: false } via useEffect:

useEffect(() => {
  const el = containerRef.current
  if (!el) return
  const handler = (e: TouchEvent) => {
    if (e.touches.length === 2) e.preventDefault()
  }
  el.addEventListener('touchmove', handler, { passive: false })
  return () => el.removeEventListener('touchmove', handler)
}, [])

In practice, pinch-to-zoom converts well. In a 2024 Baymard Institute study, mobile users who successfully zoomed product images showed a 23% lower abandonment rate on PDPs. It's not a nice-to-have. For inspiration on cohesive PDP styling, the Empire UI templates include several ecommerce layouts where this gallery pattern slots in directly.

One more thing — reset zoom to 1× when the user swipes to a new image. It's jarring to land on a new image at 3.5× zoom. Reset both zoom and pan in your setActiveIndex handler.

Performance: Lazy Loading and Image Sizing

Product galleries are LCP killers. The main image is almost always the largest paint on the page. You want loading="eager" and fetchpriority="high" on the first image, and loading="lazy" on everything else including thumbnails.

<img
  src={images[0]}
  alt={altText}
  loading="eager"
  fetchPriority="high"
  decoding="async"
/>
{images.slice(1).map((src, i) => (
  <img key={src} src={src} loading="lazy" decoding="async" />
))}

If you're on Next.js, use next/image with priority on the active image and swap to non-priority for inactive ones. Next.js 14 added sizes prop auto-generation in some cases but you still want explicit sizes for a gallery — something like sizes="(max-width: 768px) 100vw, 600px" saves 60–70% bandwidth on mobile.

Thumbnail images should be served at 128×128px maximum. Serving 1200px product shots in 64px thumbnails — which happens constantly — wastes bandwidth on every page load. If your CDN supports on-the-fly transforms (Cloudinary, Imgix, Vercel's image optimization), append ?w=128&q=60 to thumbnail URLs. The visual difference is invisible at that size and you're saving 90KB per image.

Also check out the gradient generator if you want to generate placeholder gradients that match your product's dominant colors while images load — it's a nice progressive loading pattern that keeps the page feeling stable rather than jumping around with skeleton loaders.

FAQ

How do I prevent touch swipe from conflicting with page scroll?

Set touch-action: pan-y on the gallery container. This tells the browser you only want vertical scroll as a native gesture, so horizontal swipes are handled by your JS without fighting the scroll engine.

Should I use a library like react-medium-image-zoom or build from scratch?

Build from scratch for focused product galleries — you'll save 30–50KB. Use react-medium-image-zoom if you need accessible full-screen lightbox with focus trap and ARIA live regions already handled.

Why does my zoom feel laggy on mobile?

You're likely hitting passive event listener restrictions. Attach your touchmove handler with { passive: false } via useEffect, and make sure you're using CSS transform (GPU-accelerated) not top/left positioning.

How many thumbnail images before I need virtualization?

Practically, up to about 20 thumbnails renders fine without virtualization. Above that, look at react-window or a simple intersection-observer approach to unmount off-screen thumbnails.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Image Cropper in React: Crop, Zoom, Rotate Before UploadProduct Filter Sidebar in React: Price Range, Multi-Select, URL StateE-Commerce Product Page in Tailwind: Gallery, Options, CTAE-Commerce Product Card Design: 8 Layouts That Actually Convert