A product image gallery with a thumbnail rail and pointer-tracked zoom-on-hover over the main stage.
npx shadcn@latest add @paragon/product-gallery"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>
);
}