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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/product-gallery.jsonPrompt
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 movingtransform-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
srcand callsload(); the removal alone leaves an in-flight download running, and a detached element keeps playing audio until it is collected.
Audio Trimmer
A waveform you cut on: two handles bound the kept region, everything outside dims, zoom follows the selection, and a preview starts at the in point and stops itself at the out point.
Stories Viewer
A story-format viewer — one segmented progress bar per item, tap zones for previous/next, press-and-hold to pause, and auto-advance across image and video stories.