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