E-commerce

Product Gallery

A product image gallery with a thumbnail rail and pointer-tracked zoom-on-hover over the main stage.

Install

npx shadcn@latest add @paragon/product-gallery

product-gallery.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface GalleryImage {
  /** Image src; when omitted a deterministic gradient placeholder renders. */
  src?: string;
  alt: string;
  /** Optional gradient seed used for the placeholder swatch. */
  hue?: number;
}

export interface ProductGalleryProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  images: GalleryImage[];
  /** Initial active thumbnail index. */
  defaultIndex?: number;
  /** Enable magnifier zoom-on-hover over the main image. */
  zoom?: boolean;
  /** Magnification factor when zoom is on. */
  zoomScale?: number;
  onIndexChange?: (index: number) => void;
}

/** Deterministic placeholder fill so demos need no real assets. */
function placeholder(hue: number) {
  const a = `hsl(${hue} 55% 82%)`;
  const b = `hsl(${(hue + 40) % 360} 60% 66%)`;
  return `linear-gradient(135deg, ${a}, ${b})`;
}

/**
 * A product image gallery: a large stage with a thumbnail rail (below on
 * narrow, left on wide). Thumbnails roving-tabindex + arrow keys. When `zoom`
 * is on, hovering the stage tracks the pointer and magnifies via a
 * transform-origin lens (pointer-only, disabled for touch and reduced motion).
 */
export function ProductGallery({
  images,
  defaultIndex = 0,
  zoom = true,
  zoomScale = 2,
  onIndexChange,
  className,
  ...props
}: ProductGalleryProps) {
  const [index, setIndex] = React.useState(
    Math.min(Math.max(defaultIndex, 0), Math.max(images.length - 1, 0)),
  );
  const [lens, setLens] = React.useState<{ x: number; y: number } | null>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);

  const select = (i: number) => {
    const next = (i + images.length) % images.length;
    setIndex(next);
    onIndexChange?.(next);
  };

  const active = images[index];

  const onMove = (e: React.PointerEvent) => {
    if (!zoom || e.pointerType !== "mouse") return;
    const rect = stageRef.current?.getBoundingClientRect();
    if (!rect) return;
    setLens({
      x: ((e.clientX - rect.left) / rect.width) * 100,
      y: ((e.clientY - rect.top) / rect.height) * 100,
    });
  };

  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 sm:flex-row-reverse",
        className,
      )}
      {...props}
    >
      <div
        ref={stageRef}
        onPointerMove={onMove}
        onPointerLeave={() => setLens(null)}
        className="relative aspect-square min-w-0 flex-1 overflow-hidden rounded-xl bg-secondary shadow-border"
      >
        {images.map((img, i) => (
          <div
            key={i}
            aria-hidden={i !== index}
            className="absolute inset-0 transition-opacity duration-200 ease-out motion-reduce:transition-none"
            style={{ opacity: i === index ? 1 : 0 }}
          >
            {img.src ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={img.src}
                alt={img.alt}
                className="size-full object-cover"
                style={{
                  transition: "transform 150ms var(--ease-out)",
                  transform:
                    lens && i === index
                      ? `scale(${zoomScale})`
                      : "scale(1)",
                  transformOrigin: lens ? `${lens.x}% ${lens.y}%` : "center",
                }}
                draggable={false}
              />
            ) : (
              <div
                role="img"
                aria-label={img.alt}
                className="size-full motion-reduce:!scale-100"
                style={{
                  background: placeholder(img.hue ?? i * 47),
                  transition: "transform 150ms var(--ease-out)",
                  transform:
                    lens && i === index ? `scale(${zoomScale})` : "scale(1)",
                  transformOrigin: lens ? `${lens.x}% ${lens.y}%` : "center",
                }}
              />
            )}
          </div>
        ))}
        {zoom && (
          <span
            aria-hidden
            className="pointer-events-none absolute bottom-2 right-2 rounded-md bg-background/70 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground backdrop-blur-sm"
          >
            Hover to zoom
          </span>
        )}
      </div>

      <div
        role="tablist"
        aria-label="Product images"
        aria-orientation="vertical"
        className="flex gap-2 sm:w-16 sm:flex-col"
      >
        {images.map((img, i) => (
          <button
            key={i}
            type="button"
            role="tab"
            aria-selected={i === index}
            aria-label={img.alt}
            tabIndex={i === index ? 0 : -1}
            onClick={() => select(i)}
            onKeyDown={(e) => {
              if (e.key === "ArrowDown" || e.key === "ArrowRight") {
                e.preventDefault();
                select(index + 1);
              } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
                e.preventDefault();
                select(index - 1);
              }
            }}
            className={cn(
              "relative aspect-square flex-1 overflow-hidden rounded-lg outline-none transition-[box-shadow] duration-150 ease-out sm:flex-none",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              i === index
                ? "shadow-[0_0_0_2px_var(--color-foreground)]"
                : "shadow-border hover:shadow-border-hover",
            )}
          >
            {img.src ? (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={img.src}
                alt=""
                className="size-full object-cover"
                draggable={false}
              />
            ) : (
              <span
                className="block size-full"
                style={{ background: placeholder(img.hue ?? i * 47) }}
              />
            )}
          </button>
        ))}
      </div>
    </div>
  );
}