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
└── Lightbox

See Product Page Architecture for the complete Liquid section setup and image data serialization.

Data Source

Prop/StateSourceLiquid Field
imagesParent (ProductPage)product.images
images[].idLiquid JSONimage.id
images[].urlLiquid JSONimage.src | image_url
images[].altLiquid JSONimage.alt
currentImageIdParent (ProductPage) / Local stateLinked to selected variant’s image_id
productTitleParent (ProductPage)product.title
isLightboxOpenLocal state-
isZoomedLocal state-
zoomPositionLocal state- (mouse position)
┌────────────────────────────────────────────┐
│ ┌──────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ Main Image │ │
│ │ (zoom on hover) │ │
│ │ │ │
│ │ │ │
│ │ [🔍 View] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ Thumbnail Navigation │
└────────────────────────────────────────────┘
src/components/product/MediaGallery/MediaGallery.tsx
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

src/components/product/MediaGallery/MainImage.tsx
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>
);
}
src/components/product/MediaGallery/MainImage.module.css
.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

src/components/product/MediaGallery/ThumbnailStrip.tsx
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>
);
}
src/components/product/MediaGallery/ThumbnailStrip.module.css
.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;
}
src/components/product/MediaGallery/Lightbox.tsx
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>
);
}
src/components/product/MediaGallery/Lightbox.module.css
.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

  1. Hover zoom: Transform scale with dynamic origin based on mouse position
  2. Thumbnail navigation: Quick access to all product images
  3. Lightbox: Fullscreen view with keyboard navigation
  4. Image preloading: Load thumbnails lazily, main image eagerly
  5. Variant images: Switch images when variant selection changes
  6. 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...