Product Page Components Intermediate 15 min read
Media Gallery with Zoom and Lightbox
Build a product media gallery with thumbnail navigation, image zoom on hover, and a fullscreen lightbox. Handle variant-specific images.
A great media gallery helps customers examine products closely. We’ll build one with thumbnail navigation, hover zoom, and a fullscreen lightbox.
Theme Integration
This component is part of the product page component hierarchy:
sections/product-main.liquid└── <div id="product-page-root"> └── ProductPage (React) └── MediaGallery ← You are here ├── ThumbnailStrip └── LightboxSee Product Page Architecture for the complete Liquid section setup and image data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
images | Parent (ProductPage) | product.images |
images[].id | Liquid JSON | image.id |
images[].url | Liquid JSON | image.src | image_url |
images[].alt | Liquid JSON | image.alt |
currentImageId | Parent (ProductPage) / Local state | Linked to selected variant’s image_id |
productTitle | Parent (ProductPage) | product.title |
isLightboxOpen | Local state | - |
isZoomed | Local state | - |
zoomPosition | Local state | - (mouse position) |
Gallery Structure
┌────────────────────────────────────────────┐│ ┌──────────────────────────────────────┐ ││ │ │ ││ │ │ ││ │ Main Image │ ││ │ (zoom on hover) │ ││ │ │ ││ │ │ ││ │ [🔍 View] │ ││ └──────────────────────────────────────┘ ││ ││ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ││ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ ││ └────┘ └────┘ └────┘ └────┘ └────┘ ││ Thumbnail Navigation │└────────────────────────────────────────────┘Media Gallery Component
import { useState, useCallback } from 'react';import type { ProductImage } from '@/types/product';import { MainImage } from './MainImage';import { ThumbnailStrip } from './ThumbnailStrip';import { Lightbox } from './Lightbox';import styles from './MediaGallery.module.css';
interface MediaGalleryProps { images: ProductImage[]; // All product images. currentImageId: number | null; // Currently displayed image ID. onImageSelect: (imageId: number) => void; // Callback when image changes. productTitle: string; // For alt text fallback.}
/** * MediaGallery displays product images with thumbnail navigation and lightbox. * Coordinates between main image, thumbnails, and fullscreen lightbox. */export function MediaGallery({ images, currentImageId, onImageSelect, productTitle,}: MediaGalleryProps) { const [isLightboxOpen, setIsLightboxOpen] = useState(false);
// Find the index of the currently displayed image. const currentIndex = images.findIndex((image) => image.id === currentImageId); const currentImage = currentIndex >= 0 ? images[currentIndex] : images[0];
// Navigate to previous image (wraps to last image). const handlePrevious = useCallback(() => { const newIndex = currentIndex > 0 ? currentIndex - 1 : images.length - 1; onImageSelect(images[newIndex].id); }, [currentIndex, images, onImageSelect]);
// Navigate to next image (wraps to first image). const handleNext = useCallback(() => { const newIndex = currentIndex < images.length - 1 ? currentIndex + 1 : 0; onImageSelect(images[newIndex].id); }, [currentIndex, images, onImageSelect]);
// Handle case where product has no images. if (images.length === 0) { return <div className={styles.placeholder}>No images available</div>; }
return ( <div className={styles.gallery}> {/* Main large image with zoom and navigation */} <MainImage image={currentImage} productTitle={productTitle} onOpenLightbox={() => setIsLightboxOpen(true)} onPrevious={handlePrevious} onNext={handleNext} hasPrevious={images.length > 1} hasNext={images.length > 1} />
{/* Thumbnail strip - only shown when multiple images exist */} {images.length > 1 && ( <ThumbnailStrip images={images} currentImageId={currentImageId} onSelect={onImageSelect} /> )}
{/* Fullscreen lightbox modal */} <Lightbox isOpen={isLightboxOpen} images={images} currentIndex={currentIndex >= 0 ? currentIndex : 0} onClose={() => setIsLightboxOpen(false)} onPrevious={handlePrevious} onNext={handleNext} productTitle={productTitle} /> </div> );}Main Image with Zoom
import { useState, useRef, useCallback } from 'react';import type { ProductImage } from '@/types/product';import styles from './MainImage.module.css';
interface MainImageProps { image: ProductImage; // Current image to display. productTitle: string; // Fallback alt text. onOpenLightbox: () => void; // Opens fullscreen lightbox. onPrevious: () => void; // Navigate to previous image. onNext: () => void; // Navigate to next image. hasPrevious: boolean; // Whether navigation arrows should show. hasNext: boolean;}
/** * MainImage displays the primary product image with hover zoom effect. * Clicking opens the lightbox; hovering shows a magnified view. */export function MainImage({ image, productTitle, onOpenLightbox, onPrevious, onNext, hasPrevious, hasNext,}: MainImageProps) { const containerRef = useRef<HTMLDivElement>(null); const [isZoomed, setIsZoomed] = useState(false); // Tracks mouse position as percentage for zoom origin. const [zoomPosition, setZoomPosition] = useState({ x: 50, y: 50 });
// Calculate mouse position as percentage of image dimensions. const handleMouseMove = useCallback((event: React.MouseEvent) => { if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); // Convert pixel position to percentage (0-100). const x = ((event.clientX - rect.left) / rect.width) * 100; const y = ((event.clientY - rect.top) / rect.height) * 100;
setZoomPosition({ x, y }); }, []);
// Enable zoom on mouse enter. const handleMouseEnter = useCallback(() => { setIsZoomed(true); }, []);
// Disable zoom on mouse leave. const handleMouseLeave = useCallback(() => { setIsZoomed(false); }, []);
return ( <div className={styles.container}> {/* Image container handles zoom and click events */} <div ref={containerRef} className={styles.imageWrapper} onMouseMove={handleMouseMove} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={onOpenLightbox} role="button" tabIndex={0} aria-label="Open fullscreen gallery" > {/* Main image with dynamic zoom transform */} <img src={image.url} alt={image.alt || productTitle} className={styles.image} style={ isZoomed ? { // Zoom origin follows mouse position. transformOrigin: `${zoomPosition.x}% ${zoomPosition.y}%`, transform: 'scale(2)', // 2x zoom. } : undefined } />
{/* Expand button for lightbox (alternative to clicking image) */} <button type="button" className={styles.expandButton} onClick={(event) => { event.stopPropagation(); // Prevent triggering parent onClick. onOpenLightbox(); }} aria-label="Open fullscreen" > <ExpandIcon /> </button> </div>
{/* Navigation arrows for multi-image products */} {(hasPrevious || hasNext) && ( <div className={styles.navigation}> <button type="button" className={styles.navButton} onClick={onPrevious} disabled={!hasPrevious} aria-label="Previous image" > <ArrowIcon direction="left" /> </button> <button type="button" className={styles.navButton} onClick={onNext} disabled={!hasNext} aria-label="Next image" > <ArrowIcon direction="right" /> </button> </div> )} </div> );}
/** * Expand/fullscreen icon for the lightbox trigger button. */function ExpandIcon() { return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" /> </svg> );}
/** * Arrow icon for navigation buttons. */function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"> <path d={direction === 'left' ? 'M12 15l-5-5 5-5' : 'M8 15l5-5-5-5'} /> </svg> );}.container { position: relative;}
.imageWrapper { position: relative; aspect-ratio: 1; overflow: hidden; background: var(--color-background-subtle); border-radius: 8px; cursor: zoom-in;}
.image { width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out;}
.expandButton { position: absolute; bottom: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: none; border-radius: 8px; background: var(--color-background); color: var(--color-text); cursor: pointer; opacity: 0; transition: opacity 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);}
.imageWrapper:hover .expandButton { opacity: 1;}
.navigation { position: absolute; top: 50%; left: 0; right: 0; transform: translateY(-50%); display: flex; justify-content: space-between; padding: 0 0.5rem; pointer-events: none;}
.navButton { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: none; border-radius: 50%; background: var(--color-background); color: var(--color-text); cursor: pointer; pointer-events: auto; opacity: 0; transition: opacity 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);}
.container:hover .navButton { opacity: 1;}
.navButton:disabled { opacity: 0.3; cursor: not-allowed;}Thumbnail Strip
import type { ProductImage } from '@/types/product';import styles from './ThumbnailStrip.module.css';
interface ThumbnailStripProps { images: ProductImage[]; // All product images. currentImageId: number | null; // Currently selected image. onSelect: (imageId: number) => void; // Callback when thumbnail is clicked.}
/** * ThumbnailStrip renders a horizontal row of clickable image thumbnails. * Highlights the currently selected image. */export function ThumbnailStrip({ images, currentImageId, onSelect }: ThumbnailStripProps) { return ( <div className={styles.strip}> {images.map((image) => ( <button key={image.id} type="button" className={`${styles.thumbnail} ${image.id === currentImageId ? styles.active : ''}`} onClick={() => onSelect(image.id)} aria-label={image.alt || 'Product image'} aria-current={image.id === currentImageId ? 'true' : undefined} // Accessibility: mark current. > {/* Use smaller image size for thumbnails to reduce bandwidth */} <img src={image.url.replace('width=1200', 'width=150')} alt="" // Decorative - aria-label on button provides context. className={styles.image} loading="lazy" // Lazy load thumbnails. /> </button> ))} </div> );}.strip { display: flex; gap: 0.5rem; margin-top: 1rem; overflow-x: auto; padding-bottom: 0.5rem;}
.thumbnail { flex-shrink: 0; width: 70px; height: 70px; padding: 0; border: 2px solid transparent; border-radius: 6px; background: var(--color-background-subtle); cursor: pointer; overflow: hidden; transition: border-color 0.15s ease;}
.thumbnail:hover { border-color: var(--color-border);}
.thumbnail.active { border-color: var(--color-text);}
.image { width: 100%; height: 100%; object-fit: cover;}Lightbox Component
import { useEffect, useCallback } from 'react';import { createPortal } from 'react-dom'; // Renders outside component tree.import type { ProductImage } from '@/types/product';import styles from './Lightbox.module.css';
interface LightboxProps { isOpen: boolean; // Whether lightbox is visible. images: ProductImage[]; // All images to cycle through. currentIndex: number; // Index of currently displayed image. onClose: () => void; // Close the lightbox. onPrevious: () => void; // Navigate to previous image. onNext: () => void; // Navigate to next image. productTitle: string; // Fallback alt text.}
/** * Lightbox displays images in fullscreen with keyboard navigation. * Uses createPortal to render outside the component tree for proper z-index. */export function Lightbox({ isOpen, images, currentIndex, onClose, onPrevious, onNext, productTitle,}: LightboxProps) { const currentImage = images[currentIndex];
// Set up keyboard navigation and body scroll lock when open. useEffect(() => { if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { case 'Escape': onClose(); // Close on Escape. break; case 'ArrowLeft': onPrevious(); // Previous image on left arrow. break; case 'ArrowRight': onNext(); // Next image on right arrow. break; } };
document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; // Prevent background scrolling.
// Cleanup: remove listener and restore scrolling. return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [isOpen, onClose, onPrevious, onNext]);
// Don't render anything when closed. if (!isOpen) return null;
// Render via portal directly into document.body. return createPortal( <div className={styles.overlay} onClick={onClose} role="dialog" aria-modal="true"> {/* Content container - stops click propagation to prevent accidental close */} <div className={styles.content} onClick={(event) => event.stopPropagation()}> {/* Close button in top corner */} <button type="button" className={styles.close} onClick={onClose} aria-label="Close lightbox" > <CloseIcon /> </button>
{/* Previous button */} <button type="button" className={`${styles.nav} ${styles.prev}`} onClick={onPrevious} aria-label="Previous image" > <ArrowIcon direction="left" /> </button>
{/* Main lightbox image */} <img src={currentImage.url} alt={currentImage.alt || productTitle} className={styles.image} />
{/* Next button */} <button type="button" className={`${styles.nav} ${styles.next}`} onClick={onNext} aria-label="Next image" > <ArrowIcon direction="right" /> </button>
{/* Image counter (e.g., "2 / 5") */} <div className={styles.counter}> {currentIndex + 1} / {images.length} </div> </div> </div>, document.body // Portal target. );}
/** * X icon for close button. */function CloseIcon() { return ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M18 6L6 18M6 6l12 12" /> </svg> );}
/** * Arrow icon for navigation buttons. */function ArrowIcon({ direction }: { direction: 'left' | 'right' }) { return ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} /> </svg> );}.overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.95); animation: fadeIn 0.2s ease;}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
.content { position: relative; max-width: 90vw; max-height: 90vh;}
.image { max-width: 100%; max-height: 90vh; object-fit: contain;}
.close { position: absolute; top: -3rem; right: 0; padding: 0.5rem; border: none; background: transparent; color: white; cursor: pointer;}
.nav { position: absolute; top: 50%; transform: translateY(-50%); padding: 1rem; border: none; background: rgba(255, 255, 255, 0.1); border-radius: 50%; color: white; cursor: pointer; transition: background 0.2s ease;}
.nav:hover { background: rgba(255, 255, 255, 0.2);}
.prev { left: -4rem;}
.next { right: -4rem;}
.counter { position: absolute; bottom: -2rem; left: 50%; transform: translateX(-50%); color: white; font-size: 0.875rem;}Key Takeaways
- Hover zoom: Transform scale with dynamic origin based on mouse position
- Thumbnail navigation: Quick access to all product images
- Lightbox: Fullscreen view with keyboard navigation
- Image preloading: Load thumbnails lazily, main image eagerly
- Variant images: Switch images when variant selection changes
- Accessibility: Keyboard controls and ARIA labels
In the next lesson, we’ll build the Variant Selector component.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...