Expanding gallery
An image opens from its exact place in the grid, becomes a spacious viewer, and finds its way back.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/expanding-gallery.jsonOverview
A gallery with a sense of place. Select a study and the image grows from its thumbnail into a full-screen viewer, keeping its proportions. Its caption follows a moment later. Browse with the arrows, keyboard or a swipe; the next study arrives from the direction you moved. Close it and the current image returns to its own thumbnail. The viewer is a native modal dialog, so the page behind it stays inert and keyboard focus stays inside.
States
- open
- The viewer measures the thumbnail and destination, then expands the image over 560ms. The caption follows after 180ms.
- browse
- Previous and next images enter from the direction of travel in 380ms. Navigation stops at the ends.
- swipe
- The image follows horizontal dragging. A 64px drag or a deliberate quick flick advances; a short drag settles back. The ends resist movement.
- close
- The current image returns to its measured thumbnail in 380ms. If its thumbnail has left the viewport, it fades away instead.
- unavailable
- A failed image shows a labelled fallback without changing the gallery’s dimensions.
Usage
import { ExpandingGallery } from "@/components/expanding-gallery";<ExpandingGallery label="Selected work" images={[ { id: "campaign", src: "/images/campaign.webp", alt: "Amber bottle in afternoon light", title: "Afternoon study", width: 1536, height: 1024, meta: "Campaign · 2026" }, { id: "detail", src: "/images/detail.webp", alt: "A close-up of the bottle", title: "The essential form", width: 1536, height: 1024 }, ]}/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Track the selected study
Use stable image IDs and real intrinsic dimensions. Supply your own local or remote image URLs.
import { ExpandingGallery, type GalleryImage } from "@/components/expanding-gallery";export function Portfolio({ studies }: { studies: GalleryImage[] }) { return ( <ExpandingGallery images={studies} label="Studio / Selected studies" onImageChange={(image) => { if (image) console.log("Opened study", image.id); }} /> );}Props
| Prop | Type | Description |
|---|---|---|
| images | GalleryImage[] | Stable id, src, alt, title, width and height; optional description and meta. Intrinsic proportions are preserved. |
| label | stringdefault Selected work | The gallery heading and viewer heading. |
| onImageChange | (image: GalleryImage | null) => void | Called when a study opens or changes. Receives null on close. |
| className | string | Additional classes on the gallery root. Standard div attributes are forwarded. |
Accessibility and motion
- The viewer is a top-layer popover with role="dialog" and aria-modal: it opens above everything without making the page inert, which on a large page restyles every element in the frame it opens. Focus is trapped inside, the page behind does not scroll, Escape closes and focus returns to the current thumbnail, including after browsing.
- Left and Right browse, Home and End jump to the first and last image. Buttons expose disabled states at the ends.
- Pointer cancellation restores the image. Horizontal swipes leave vertical touch movement to the browser.
- Reduced motion removes expansion, sliding, overshoot and thumbnail zoom. Navigation and all controls remain available.
- Image IDs must be unique; width and height should match the source asset. The component handles an empty collection.
- The entry point, viewer and motion helpers install together. No global animation stylesheet or animation library is required.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
'use client';import * as React from 'react';import { ArrowUpRight } from 'lucide-react';import { GalleryViewer } from './gallery-viewer';import { GalleryPicture, type GalleryImage } from './gallery-picture';import { INTERACTION_FOCUS } from './ui-interaction-motion';import { NumberRoll } from './number-roll';export type { GalleryImage } from './gallery-picture';export interface ExpandingGalleryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> { images: GalleryImage[]; label?: string; onImageChange?: (image: GalleryImage | null) => void;}/** An image keeps its identity from the thumbnail to the viewer and back. */export function ExpandingGallery({ images, label = 'Selected work', onImageChange, className = '', ...props }: ExpandingGalleryProps) { const [selected, setSelected] = React.useState<string | null>(null); const frames = React.useRef(new Map<string, HTMLDivElement>()); const triggers = React.useRef(new Map<string, HTMLButtonElement>()); const openingRect = React.useRef<DOMRect | null>(null); const index = images.findIndex((image) => image.id === selected); const visible = index >= 0; const select = (next: number) => { const image = images[next]; if (!image) return; if (selected === null) openingRect.current = frames.current.get(image.id)?.getBoundingClientRect() ?? null; setSelected(image.id); onImageChange?.(image); }; const close = () => { setSelected(null); onImageChange?.(null); // Focus returns to the thumbnail of the image you were on, including after browsing. triggers.current.get(selected ?? '')?.focus({ preventScroll: true }); }; return ( <div className={`w-full text-foreground ${className}`} {...props}> <div className="mb-5 flex items-baseline justify-between gap-4"> <span className="text-[13px] font-medium">{label}</span> <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"><NumberRoll value={images.length} format={{ minimumIntegerDigits: 2 }} /> studies</span> </div> <div className="grid grid-cols-2 gap-x-3 gap-y-5 sm:gap-x-4"> {images.map((image, i) => ( <button key={image.id} type="button" aria-label={`Open ${image.title}`} aria-haspopup="dialog" ref={(el) => { if (el) triggers.current.set(image.id, el); else triggers.current.delete(image.id); }} onClick={() => select(i)} className={`group min-w-0 cursor-zoom-in rounded-2xl text-left ${INTERACTION_FOCUS}`}> <div ref={(el) => { if (el) frames.current.set(image.id, el); else frames.current.delete(image.id); }} className="relative overflow-hidden rounded-2xl bg-muted" style={{ aspectRatio: `${Math.max(1, image.width)} / ${Math.max(1, image.height)}`, visibility: visible && selected === image.id ? 'hidden' : undefined }}> <div className="size-full transition-transform duration-500 ease-out group-hover:scale-[1.025] motion-reduce:transform-none"> <GalleryPicture key={image.src} image={image} /> </div> <span className="absolute bottom-3 right-3 flex size-7 items-center justify-center rounded-full border border-border bg-background text-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100"> <ArrowUpRight className="size-3.5" aria-hidden="true" /> </span> </div> <span className="mt-2.5 flex items-baseline justify-between gap-2 px-0.5"> <span className="truncate text-[12px] font-medium">{image.title}</span> <span className="font-mono text-[10px] text-muted-foreground">{String(i + 1).padStart(2, '0')}</span> </span> {image.meta && <span className="mt-0.5 block truncate px-0.5 text-[11px] text-muted-foreground">{image.meta}</span>} </button> ))} </div> {visible && <GalleryViewer images={images} index={index} label={label} openingRect={openingRect.current} getOrigin={(id) => frames.current.get(id)} onSelect={select} onClose={close} />} </div> );}'use client';import * as React from 'react';import { ImageOff } from 'lucide-react';export interface GalleryImage { id: string; src: string; alt: string; title: string; description?: string; /** Intrinsic dimensions keep the thumbnail and expanded image in the same proportions. */ width: number; height: number; meta?: string;}export function GalleryPicture({ image, eager = false }: { image: GalleryImage; eager?: boolean }) { const [failed, setFailed] = React.useState(false); return failed ? ( <div role="img" aria-label={`${image.alt} (image unavailable)`} className="flex size-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground"> <ImageOff className="size-6" aria-hidden="true" /><span className="text-xs">Image unavailable</span> </div> ) : ( <img src={image.src} alt={image.alt} width={image.width} height={image.height} loading={eager ? 'eager' : 'lazy'} decoding="async" draggable={false} onError={() => setFailed(true)} className="size-full object-cover" /> );}'use client';import * as React from 'react';import { ChevronLeft, ChevronRight, X } from 'lucide-react';import { GalleryPicture, type GalleryImage } from './gallery-picture';import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS, releaseVelocity, samplePointer, useInteractionReducedMotion, type PointerSample } from './ui-interaction-motion';import { NumberRoll } from './number-roll';import { TextMorph } from './text-morph';type Props = { images: GalleryImage[]; index: number; label: string; openingRect: DOMRect | null; getOrigin: (id: string) => HTMLElement | undefined; onSelect: (index: number) => void; onClose: () => void };const CONTROL = `cursor-pointer disabled:cursor-default flex size-10 shrink-0 items-center justify-center rounded-full border border-border bg-background/80 text-foreground transition-colors hover:bg-accent disabled:opacity-30 ${INTERACTION_FOCUS}`;/** * Holds the page still behind the viewer without touching <html>: changing the root's overflow restyles and * relayouts the whole page in the frame the viewer opens, which is the stutter people feel. The viewer covers the * screen, so stopping wheel and touch scrolling on it is enough. */function holdScroll(el: HTMLElement) { const stop = (event: Event) => event.preventDefault(); el.addEventListener('wheel', stop, { passive: false }); el.addEventListener('touchmove', stop, { passive: false }); return () => { el.removeEventListener('wheel', stop); el.removeEventListener('touchmove', stop); };}function rectTransform(from: DOMRect, to: DOMRect) { return `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${from.width / Math.max(1, to.width)}, ${from.height / Math.max(1, to.height)})`;}function LeavingStudy({ image, direction, from }: { image: GalleryImage; direction: number; from: number }) { const ref = React.useRef<HTMLDivElement>(null); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const animation = el.animate([{ opacity: 1, transform: `translateX(${from}px)` }, { opacity: 0, transform: `translateX(${-direction * 56}px)` }], { duration: 280, easing: EASE_OUT, fill: 'forwards' }); return () => animation.cancel(); }, [direction, from]); return <div ref={ref} aria-hidden="true" inert className="pointer-events-none absolute inset-0"><GalleryPicture image={image} eager /></div>;}export function GalleryViewer({ images, index, label, openingRect, getOrigin, onSelect, onClose }: Props) { const reduced = useInteractionReducedMotion(); const dialog = React.useRef<HTMLDivElement>(null); const closeButton = React.useRef<HTMLButtonElement>(null); const picture = React.useRef<HTMLDivElement>(null); const content = React.useRef<HTMLDivElement>(null); const surface = React.useRef<HTMLDivElement>(null); const caption = React.useRef<HTMLDivElement>(null); const animations = React.useRef<Animation[]>([]); const closing = React.useRef(false); const mounted = React.useRef(false); const previousSize = React.useRef<{ width: number; height: number } | null>(null); const first = React.useRef(true); const direction = React.useRef(1); const drag = React.useRef<{ id: number; start: number; dx: number; samples: PointerSample[] } | null>(null); const id = React.useId(); const image = images[index]; const [ghost, setGhost] = React.useState<{ image: GalleryImage; direction: number; from: number } | null>(null); React.useEffect(() => { if (!ghost) return; const timer = window.setTimeout(() => setGhost(null), 300); return () => window.clearTimeout(timer); }, [ghost]); const cancelAnimations = () => { animations.current.forEach((animation) => animation.cancel()); animations.current = []; }; React.useLayoutEffect(() => { const el = dialog.current; if (!el) return; mounted.current = true; // A manual popover sits in the top layer like a modal dialog but doesn't make the page inert, which restyles // every element on the page twice (open and close). Modality is kept by hand: aria-modal, focus trap, Escape. el.showPopover(); closeButton.current?.focus({ preventScroll: true }); const unlock = holdScroll(el); return () => { mounted.current = false; cancelAnimations(); if (el.matches(':popover-open')) el.hidePopover(); unlock(); first.current = true; closing.current = false; }; }, []); React.useLayoutEffect(() => { const frame = picture.current; if (!frame) return; cancelAnimations(); frame.style.transform = ''; if (reduced) { first.current = false; return; } const origin = first.current ? openingRect : getOrigin(image.id)?.getBoundingClientRect(); const target = frame.getBoundingClientRect(); const entering = first.current; first.current = false; if (entering) animations.current.push(frame.animate(origin ? [ { transform: rectTransform(origin, target), borderRadius: '16px' }, { transform: 'none', borderRadius: '12px' }, ] : [{ opacity: 0 }, { opacity: 1 }], { duration: 560, easing: EASE_OUT })); else if (content.current) animations.current.push(content.current.animate([ { transform: `translateX(${direction.current * 40}px) scale(0.985)`, opacity: 0 }, { transform: 'none', opacity: 1 }, ], { duration: 380, easing: EASE_OUT })); const size = previousSize.current; previousSize.current = null; if (!entering && size && (Math.abs(size.width - target.width) > 0.5 || Math.abs(size.height - target.height) > 0.5)) { animations.current.push(frame.animate([ { width: `${size.width}px`, height: `${size.height}px` }, { width: `${target.width}px`, height: `${target.height}px` }, ], { duration: 380, easing: EASE_OUT })); } if (entering && surface.current) animations.current.push(surface.current.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 320, easing: 'ease-out' })); if (caption.current) animations.current.push(caption.current.animate([ { opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }, ], { duration: 320, delay: entering ? 180 : 60, fill: 'backwards', easing: EASE_OUT })); return cancelAnimations; // Geometry is sampled on selection; a resize simply reflows the settled viewer. // eslint-disable-next-line react-hooks/exhaustive-deps }, [image.id, reduced]); const navigate = (next: number) => { if (closing.current || next < 0 || next >= images.length || next === index) return; direction.current = Math.sign(next - index); const rect = picture.current?.getBoundingClientRect(); previousSize.current = rect ? { width: rect.width, height: rect.height } : null; if (!reduced) setGhost({ image, direction: direction.current, from: (drag.current?.dx ?? 0) * 0.65 }); drag.current = null; onSelect(next); }; const close = async () => { if (closing.current) return; closing.current = true; const frame = picture.current; const current = frame?.getBoundingClientRect(); cancelAnimations(); const finish = () => { if (mounted.current && closing.current) { closing.current = false; if (dialog.current?.matches(':popover-open')) dialog.current.hidePopover(); onClose(); } }; if (reduced || !frame || !current) { finish(); return; } frame.style.transform = ''; const base = frame.getBoundingClientRect(); const target = getOrigin(image.id)?.getBoundingClientRect(); const inView = target && target.bottom > 0 && target.top < window.innerHeight; const animation = frame.animate([ { transform: rectTransform(current, base), opacity: 1, borderRadius: '12px' }, { transform: inView ? rectTransform(target, base) : 'scale(0.96)', opacity: inView ? 1 : 0, borderRadius: '16px' }, ], { duration: 380, easing: EASE_OUT, fill: 'forwards' }); animations.current.push(animation); if (surface.current) animations.current.push(surface.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 320, fill: 'forwards' })); if (caption.current) animations.current.push(caption.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 100, fill: 'forwards' })); try { await animation.finished; finish(); } catch { finish(); } }; const finishDrag = (event: React.PointerEvent, cancelled = false) => { const state = drag.current; if (!state || state.id !== event.pointerId) return; const velocity = releaseVelocity(state.samples, event.timeStamp); const next = index + (state.dx < 0 ? 1 : -1); if (!cancelled && (Math.abs(state.dx) > 64 || (Math.abs(state.dx) > 12 && Math.abs(velocity) > 0.5)) && next >= 0 && next < images.length) navigate(next); else if (picture.current) { const frame = picture.current; const from = frame.style.transform || 'none'; frame.style.transform = ''; if (!reduced) animations.current.push(frame.animate([{ transform: from }, { transform: 'none' }], { duration: 360, easing: EASE_SETTLE })); } drag.current = null; }; return ( <div ref={dialog} popover="manual" role="dialog" aria-modal="true" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`} data-lenis-prevent onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); void close(); return; } if (event.key === 'Tab') { // Keep focus inside the viewer, as a modal dialog would. const focusable = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')]; const first = focusable[0], last = focusable[focusable.length - 1]; if (first && last && (event.shiftKey ? document.activeElement === first : document.activeElement === last)) { event.preventDefault(); (event.shiftKey ? last : first).focus(); } return; } if (event.key === 'PageUp' || event.key === 'PageDown') event.preventDefault(); if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { event.preventDefault(); navigate(event.key === 'Home' ? 0 : event.key === 'End' ? images.length - 1 : index + (event.key === 'ArrowRight' ? 1 : -1)); } }} className="fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground backdrop:bg-transparent"> <div ref={surface} className="absolute inset-0 cursor-zoom-out bg-background" onClick={() => void close()} /> <div className="pointer-events-none relative flex h-full flex-col items-center justify-center gap-5 px-4 pb-5 pt-20 sm:gap-6"> <div className="pointer-events-auto absolute inset-x-5 top-5 flex items-center justify-between sm:inset-x-8"> <span className="text-xs font-medium tracking-wide">{label}</span> <button ref={closeButton} type="button" aria-label="Close gallery" onClick={() => void close()} className={CONTROL}><X className="size-4" aria-hidden="true" /></button> </div> <div ref={picture} className="pointer-events-auto relative shrink-0 origin-top-left cursor-grab overflow-hidden rounded-xl bg-muted will-change-transform active:cursor-grabbing" style={{ aspectRatio: `${Math.max(1, image.width)} / ${Math.max(1, image.height)}`, width: `min(88vw, calc((100dvh - 260px) * ${Math.max(1, image.width) / Math.max(1, image.height)}), 980px)`, touchAction: 'pan-y' }} onPointerDown={(event) => { if (closing.current || event.button !== 0 || !event.isPrimary) return; cancelAnimations(); event.currentTarget.setPointerCapture(event.pointerId); drag.current = { id: event.pointerId, start: event.clientX, dx: 0, samples: [{ x: event.clientX, time: event.timeStamp }] }; }} onPointerMove={(event) => { const state = drag.current; if (!state || state.id !== event.pointerId) return; state.dx = event.clientX - state.start; samplePointer(state.samples, event.clientX, event.timeStamp); const edge = (index === 0 && state.dx > 0) || (index === images.length - 1 && state.dx < 0); if (!reduced) event.currentTarget.style.transform = `translateX(${state.dx * (edge ? 0.18 : 0.65)}px)`; }} onPointerUp={(event) => finishDrag(event)} onPointerCancel={(event) => finishDrag(event, true)} onLostPointerCapture={(event) => finishDrag(event, true)}> {ghost && !reduced && <LeavingStudy key={ghost.image.id} image={ghost.image} direction={ghost.direction} from={ghost.from} />} <div ref={content} className="relative size-full"><GalleryPicture key={image.src} image={image} eager /></div> </div> <div ref={caption} className="pointer-events-auto w-full max-w-[720px] px-2"> <div className="flex items-start justify-between gap-5"> <div className="min-w-0"> <h2 id={`${id}-title`} className="text-lg font-medium tracking-[-0.025em] sm:text-2xl"><TextMorph animateWidth={false}>{image.title}</TextMorph></h2> <p id={`${id}-description`} className="mt-1 line-clamp-2 max-w-[500px] text-[12px] leading-relaxed text-muted-foreground sm:text-sm"><TextMorph animateWidth={false}>{image.description ?? image.alt}</TextMorph></p> </div> <div className="flex shrink-0 items-center gap-2"> <button type="button" aria-label="Previous image" disabled={index === 0} onClick={() => navigate(index - 1)} className={CONTROL}><ChevronLeft className="size-4" aria-hidden="true" /></button> <button type="button" aria-label="Next image" disabled={index === images.length - 1} onClick={() => navigate(index + 1)} className={CONTROL}><ChevronRight className="size-4" aria-hidden="true" /></button> </div> </div> <p aria-live="polite" aria-atomic="true" className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"><NumberRoll value={index + 1} format={{ minimumIntegerDigits: 2 }} /> / <NumberRoll value={images.length} format={{ minimumIntegerDigits: 2 }} />{image.meta && <span> · <TextMorph>{image.meta}</TextMorph></span>}</p> </div> </div> </div> );}'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => { const media = window.matchMedia(query); media.addEventListener('change', notify); return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() { const ref = React.useRef<T>(null); const [visible, setVisible] = React.useState(false); const [hidden, setHidden] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); observer.observe(el); const visibility = () => setHidden(document.hidden); visibility(); document.addEventListener('visibilitychange', visibility); return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); }; }, []); return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties { return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) { samples.push({ x, time }); while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) { if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0; const first = samples[0], last = samples[samples.length - 1]; return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) { const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping); const displacement = from - to; const coefficient = (velocity * 1000 + damping * omega * displacement) / damped; return Array.from({ length: 33 }, (_, i) => { const t = (i / 32) * duration / 1000; const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t)); return { value: i === 32 ? to : value, offset: i / 32 }; });}Registry item: /r/expanding-gallery.json