Media

Product Gallery

A thumbnail rail driving one stage — pointer-tracked and keyboard-pannable magnification, inline video entries with duration chips, and a layout that follows its container.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, ImageOff, Play, ZoomIn, ZoomOut } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Cross-fade for the stage asset. React 19 hoists <style href> into <head> and dedupes by href,
 * so several galleries on one page still yield a single copy of the keyframes.
 */
const KEYFRAMES = `@keyframes pg-stage-in{from{opacity:0}to{opacity:1}}`

const ASPECT_CLASS = {
  square: "aspect-square",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/product-gallery.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ProductGallery" component
(lucide-react for the chevrons, play, zoom and empty icons; no other
dependencies).

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>,
  plus a ProductMediaItem type: { src: string; alt: string; thumb?: string;
  type?: "image" | "video"; duration?: string; poster?: string }.
  duration is a PRE-FORMATTED runtime like "0:46" — the component never
  formats time, so locale decisions stay with the caller.
- Props: items: ProductMediaItem[] (required, in rail order); index?: number
  (controlled) / defaultIndex?: number (0) / onIndexChange?: (index: number)
  => void; zoom?: number (2, clamped to 1-4); zoomable?: boolean (true);
  loop?: boolean (false); showCounter?: boolean (true); showArrows?: boolean
  (true); aspect?: "square" | "video" | "portrait" ("square", stage only);
  railBreakpoint?: number (560); emptyText?: string. The accessible name is
  the native aria-label prop, defaulting to "Product media" — no bespoke
  label prop for something the platform already has. className merges
  through cn().
- Derived, never stored: activeIndex = clamp(trunc(requested), 0, count - 1)
  with a non-finite value falling back to 0 and an empty array to -1;
  magnified = zoomable && item is an image && (zoomOn || hovering).
  State is only: uncontrolled index, zoomOn (the committed toggle),
  hovering (a pointer that can hover is over the stage), focal {x, y}.

Behavior
- Structure is the APG tabs pattern with one panel: the rail is
  role="tablist", each thumbnail is a role="tab" with aria-selected and
  aria-controls, and the stage is the single role="tabpanel" whose
  aria-labelledby points at the selected tab. Activation is automatic —
  moving focus in the rail selects, because the stage IS the preview.
- Rail keyboard map: ArrowRight/ArrowDown go next, ArrowLeft/ArrowUp go
  previous, Home/End jump to the ends. BOTH axes are accepted on purpose:
  the rail rotates with the container's width and nobody should have to
  work out which orientation they are standing in. Roving tabindex — the
  selected thumbnail is the rail's only tab stop, so Tab enters and leaves
  the whole rail in one press. Every walk selects AND moves DOM focus, by
  reading the thumbnail out of a ref map inside the same handler.
- loop=false stops at both ends; loop=true wraps with
  (next + count) % count, which is enough because a step only ever lands one
  place out of range. The previous/next buttons over the
  stage take aria-disabled at the bounds, never the native disabled
  attribute: stepping onto the last item would otherwise kill the button
  under the finger that just pressed it and drop focus onto <body>.
  Both buttons are simply not rendered when there is one item.
- Magnifier (image entries only): the stage image lives inside a
  <button aria-pressed={zoomOn}>. A mouse or pen entering sets hovering
  (pointerType === "touch" is ignored — a tap fires pointerenter too, and
  hover magnification on a device with no hover is a trap). pointermove
  reads the bounding rect synchronously (currentTarget is nulled after
  dispatch), computes focal = clamp01((clientX - rect.left) / rect.width)
  and the same for y, stashes it in a ref, and schedules ONE rAF if none is
  pending; the frame callback commits the latest ratio. One state write per
  frame, no matter how fast the pointer moves.
- The magnification itself is transform: scale(zoom) with
  transform-origin: focal.x*100% focal.y*100%. Panning the origin IS the
  pan, so there is no translate maths and no bounds to compute — clamping
  the origin to 0..1 is exactly what stops the frame from sliding off the
  edge of the image.
- Keyboard parity for the magnifier: Enter/Space toggles zoomOn (a real
  toggle button, so screen readers get pressed state); while magnified the
  arrow keys pan the focal point by 0.08 of the frame per press; Escape
  drops the magnifier, keeps focus, and stopPropagation()s so a surrounding
  dialog does not close on the same press. When NOT magnified the arrows are
  left alone, so the page still scrolls under someone tabbing past. A click
  with detail > 0 adopts the pointer position as the focal point; detail
  === 0 (Enter/Space) leaves the focal point where the arrows left it.
- Never listen for wheel, touchmove or gesture events, and never call
  preventDefault on them. Pinch-zoom and page scrolling over the gallery
  belong to the browser; the rail scrolls natively too. Every step therefore
  has a button and a key, and no step needs a drag.
- Layout follows the ROOT's width measured with a ResizeObserver, not a
  media query: a gallery in a 360px sidebar on a 4K monitor is narrow. At or
  above railBreakpoint the rail is a vertical column beside the stage;
  below it, a horizontal strip above it. The first render is always the
  narrow layout so SSR and hydration agree, and the measurement lands in a
  layout effect (before paint). aria-orientation follows the same flag.
  DOM order is rail then stage in both layouts, so focus order never runs
  against reading order.
- Selecting scrolls the rail's OWN scroll offset to reveal the thumbnail:
  delta = (thumbTop - railTop) - 8 when it sits above, or
  (thumbBottom - railBottom) + 8 when below, on whichever axis is
  scrolling. Never scrollIntoView — it walks up the ancestor chain and drags
  the whole page under someone who only pressed an arrow key. The first
  alignment jumps; later ones glide unless prefers-reduced-motion is set.
- Switching entries resets zoomOn and re-centres the focal point in the SAME
  commit, through a render-phase state adjustment (compare a stored
  seenIndex), not an effect. One paint later would show the incoming photo
  through the previous photo's focal point — a corner of an image nobody has
  seen yet.
- Video entries: the thumbnail gets a play badge plus the duration chip, and
  the stage renders <video controls playsInline preload="metadata"> with
  poster falling back to thumb. playsInline matters — iOS otherwise takes
  over the screen on play. Stepping away or unmounting must pause(),
  removeAttribute("src") and then load(): a detached media element keeps
  playing audio until it is collected, and dropping the src alone leaves the
  download running — load() is what aborts it. Key the element by position
  so two entries sharing a URL still get a fresh element.
- Edge cases: an empty items array renders a dashed empty frame with the
  emptyText sentence and no rail, controls or counter; a single item hides
  the arrows and reads "1 / 1"; an out-of-range or NaN index resolves to a
  real item instead of blanking the stage; the stage panel takes tabIndex=0
  only when it contains no focusable control of its own (magnifier off, one
  item, no video), so the panel is never unreachable.
- Cleanup: disconnect the ResizeObserver, remove the matchMedia listener
  (subscribe/getSnapshot through useSyncExternalStore, with a false server
  snapshot), cancelAnimationFrame any pending focal commit on unmount, and
  run the video teardown above on both index change and unmount.

Rendering & styling
- Semantic tokens only: bg-card / bg-muted / bg-background/85 / border /
  border-primary / ring-primary / ring-ring / text-foreground /
  text-muted-foreground / text-primary-foreground. No hardcoded colours, so
  the gallery inherits any theme and dark mode for free.
- Root: flex w-full gap-3, flex-row items-stretch when wide, flex-col when
  narrow. In the wide layout the rail's scroller is absolutely inset inside
  a w-16 wrapper — that is what lets the wrapper stretch to the stage's
  height while forty thumbnails scroll inside it instead of growing the row.
- Thumbnails: rounded-md border bg-muted, always shrink-0 (a scroll column
  must scroll, not compress its children), size-16 in the strip but
  "aspect-square w-full" in the column — a classic space-taking scrollbar
  would clip a fixed 64px thumbnail against the rail's edge. Unselected at
  opacity-70 (hover:opacity-100), selected with border-primary plus
  ring-2 ring-primary ring-inset. Rings are INSET, never ring-offset, for
  the same reason. loading="lazy" and decoding="async" on every thumbnail;
  the stage image is eager.
- Stage: relative overflow-hidden rounded-xl border bg-muted plus the aspect
  class; the image is object-cover with transition-transform duration-200
  and motion-reduce:transition-none, and a short opacity keyframe replayed
  by keying the image on the position. draggable={false} on every image —
  native image dragging would otherwise swallow the pointer stream the
  magnifier tracks.
- Overlays: rounded pill chips on bg-background/85 with backdrop-blur and
  pointer-events-none for the "3 / 7" counter (tabular-nums) and the
  zoom-state chip; previous/next as bordered circular buttons at
  left-2 / right-2. Chips are aria-hidden — a polite role="status" region
  already announces "Image 3 of 7: <alt>" on every change.
- Reduced motion removes decoration only: transitions and the fade become
  cuts, the rail aligns instantly, and magnification, stepping and video
  keep working exactly as before.

Customization levers
- Density and shape: aspect picks the stage crop ("portrait" for apparel,
  "video" for hardware b-roll); the thumbnail size is one size-16 to change,
  and the rail wrapper's w-16 must change with it. Swap object-cover for
  object-contain if products must never be cropped — note the focal maths
  then addresses the letterboxed box, so the magnifier drifts slightly at
  the bands.
- Magnification strength: zoom 1.5 reads as "a closer look", 3-4 as "inspect
  the weave". PAN_STEP (0.08) is the arrow-key travel; lower it for large
  images where precision matters more than speed.
- Chrome: drop showCounter and showArrows for a bare stage driven only by
  the rail; drop zoomable for diagrams and size charts. To put the rail on
  the other side, add flex-row-reverse (or flex-col-reverse) through
  className — and know that focus order then runs against visual order.
- Breakpoint: railBreakpoint is the single knob for the flip. Raise it when
  the gallery sits in a two-column detail layout, lower it when it owns the
  page.
- Data shape: thumb defaults to src, so a small set can ship one URL per
  entry; give video entries a real still, since a poster frame is the only
  thing the rail can show. duration is free text — "0:46", "46s", "LIVE".
- Wiring: index / onIndexChange makes the gallery follow a colour or size
  selector; keep it uncontrolled when the gallery is the only owner of the
  selection.

Concepts

  • Rail drives stage — the thumbnails are tabs and the stage is their single panel, with automatic activation: moving focus in the rail already changes the stage, because previewing is the whole point of moving. Both arrow axes are live, so the keyboard map survives the layout flip.
  • Origin panning — magnification is scale() with a moving transform-origin, so the pointer ratio and the arrow-key step address the same 0–1 space and clamping that space is all the bounds checking there is. No translate maths, no scroll container, nothing to keep in sync.
  • Container-driven layout — the flip between a vertical rail and a horizontal strip is measured off the component's own box, not the viewport, so the same gallery behaves correctly in a sidebar, a modal and a full-width page without the consumer passing breakpoints down.
  • Same-commit reset — selecting a new entry drops the magnifier and re-centres the focal point during render rather than in an effect, so an incoming photo is never shown for one frame through the previous photo's focal point.
  • Gesture-free by construction — no wheel, touch or drag listener exists, so pinch-zoom and page scrolling are never intercepted; every step is reachable by a button and a key, which is why there is nothing to reinstate under prefers-reduced-motion.
  • Detach, then load — leaving a video pauses it, removes its src and calls load(); the removal alone leaves an in-flight download running, and a detached element keeps playing audio until it is collected.

On This Page