Expand gallery
A thumbnail grid where each image grows into a full view inside a real dialog.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/expand-gallery.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/expand-gallery
Five renders in a bento. Select one, then press Escape to send it back to its place.
Usage
import { ExpandGallery, ExpandGalleryItem } from "@/components/ui/expand-gallery"
<ExpandGallery aria-label="Studies">
<ExpandGalleryItem src="/work/one.jpg" alt="The new dashboard" title="Dashboard" caption="1440 x 900" />
</ExpandGallery>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ratio | number | 1.5 | On ExpandGallery. Width over height of the full view. Match your thumbnails. |
| src | string | On ExpandGalleryItem. The image, used for the thumbnail and the full view. | |
| alt | string | On ExpandGalleryItem. Also names the button that opens it. | |
| title | ReactNode | On ExpandGalleryItem. Shown under the full view, and names the dialog. | |
| caption | ReactNode | On ExpandGalleryItem. One line of mono meta beside the title. |
Source
expand-gallery/expand-gallery.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, Flip);
const MOTION = "(prefers-reduced-motion: no-preference)";
const THUMB = "[data-slot=expand-gallery-thumb]";
type Picture = { src: string; alt: string; title: React.ReactNode; caption?: React.ReactNode };
const GalleryContext = React.createContext<((picture: Picture, trigger: HTMLButtonElement) => void) | null>(null);
type ExpandGalleryProps = React.ComponentProps<"ul"> & {
/** Width over height of the full view. Match your thumbnails, so the grow is a uniform scale. */
ratio?: number;
};
/**
* A grid of thumbnails, each of which grows into a full view. The view is a native modal dialog, so
* the browser moves focus in and back, makes the background inert and gives us Escape. Without motion
* the dialog simply opens and closes.
*/
function ExpandGallery({ ratio = 1.5, className, children, ...props }: ExpandGalleryProps) {
const root = React.useRef<HTMLDivElement>(null);
const dialog = React.useRef<HTMLDialogElement>(null);
const frame = React.useRef<HTMLDivElement>(null);
const trigger = React.useRef<HTMLButtonElement>(null);
const motion = React.useRef(false);
const closing = React.useRef(false);
const [picture, setPicture] = React.useState<Picture | null>(null);
const titleId = React.useId();
const thumb = () => trigger.current?.querySelector<HTMLElement>(THUMB) ?? null;
const fades = () => gsap.utils.toArray<HTMLElement>("[data-slot=expand-gallery-fade]", root.current);
React.useEffect(
() => () => {
document.documentElement.style.overflow = "";
},
[],
);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, () => {
motion.current = true;
return () => {
motion.current = false;
};
});
return () => media.revert();
},
{ scope: root },
);
useGSAP(
() => {
const view = dialog.current;
if (!picture || !view || view.open) return;
view.showModal();
document.documentElement.style.overflow = "hidden";
const from = thumb();
if (!motion.current || !from) return;
gsap.set(from, { opacity: 0 });
gsap.fromTo(frame.current, Flip.fit(frame.current, from, { scale: true, getVars: true }) as gsap.TweenVars, {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
duration: 0.7,
ease: "power3.inOut",
});
gsap.fromTo(fades(), { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.out" });
},
{ scope: root, dependencies: [picture] },
);
const finish = () => {
const view = dialog.current;
const from = thumb();
gsap.killTweensOf(frame.current);
gsap.killTweensOf(fades());
if (from) gsap.set(from, { clearProps: "opacity" });
if (view?.open) view.close();
document.documentElement.style.overflow = "";
closing.current = false;
setPicture(null);
trigger.current?.focus({ preventScroll: true });
};
const close = () => {
const to = thumb();
if (closing.current) return;
if (!motion.current || !to) return finish();
closing.current = true;
gsap.killTweensOf(frame.current);
gsap.to(fades(), { opacity: 0, duration: 0.3, ease: "power2.in" });
Flip.fit(frame.current, to, { scale: true, duration: 0.5, ease: "power3.inOut", onComplete: finish });
};
const open = React.useCallback((next: Picture, button: HTMLButtonElement) => {
trigger.current = button;
setPicture(next);
}, []);
return (
<GalleryContext.Provider value={open}>
<div ref={root} data-slot="expand-gallery" className="min-w-0">
<ul className={cn("m-0 grid list-none grid-cols-2 gap-3 p-0 md:grid-cols-4", className)} {...props}>
{children}
</ul>
<dialog
ref={dialog}
aria-labelledby={titleId}
onCancel={(event) => {
// The browser would close at once, so hold it open for the way back.
event.preventDefault();
close();
}}
onClose={() => {
if (picture) finish();
}}
className="m-0 h-dvh max-h-none w-full max-w-none overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent"
>
{picture ? (
<div className="relative grid size-full place-items-center p-6">
<div
data-slot="expand-gallery-fade"
aria-hidden="true"
onClick={close}
className="absolute inset-0 bg-background"
/>
<div className="relative grid justify-items-center gap-4">
<div
ref={frame}
className="w-[min(calc(100vw-3rem),calc((100dvh-10rem)*var(--gallery-ratio)))] overflow-hidden rounded-lg border bg-muted"
style={{ "--gallery-ratio": ratio, aspectRatio: ratio } as React.CSSProperties}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={picture.src} alt={picture.alt} className="size-full object-cover" />
</div>
<div data-slot="expand-gallery-fade" className="flex w-full items-baseline justify-between gap-6">
<p id={titleId} className="m-0 text-sm font-medium text-foreground">
{picture.title}
</p>
{picture.caption ? (
<p className="m-0 truncate font-mono text-xs text-muted-foreground">{picture.caption}</p>
) : null}
</div>
</div>
<div data-slot="expand-gallery-fade" className="absolute top-4 right-4">
<Button
type="button"
variant="outline"
size="icon"
aria-label="Close"
className="rounded-full"
onClick={close}
>
<X />
</Button>
</div>
</div>
) : null}
</dialog>
</div>
</GalleryContext.Provider>
);
}
type ExpandGalleryItemProps = Omit<React.ComponentProps<"li">, "title"> & Picture;
/** One thumbnail. Pass `alt` as it should read in the full view; the button is named after it. */
function ExpandGalleryItem({ src, alt, title, caption, className, ...props }: ExpandGalleryItemProps) {
const open = React.use(GalleryContext);
return (
<li data-slot="expand-gallery-item" className={cn("min-w-0", className)} {...props}>
<button
type="button"
aria-haspopup="dialog"
onClick={(event) => open?.({ src, alt, title, caption }, event.currentTarget)}
className="block size-full cursor-zoom-in overflow-hidden rounded-lg border bg-muted"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
data-slot="expand-gallery-thumb"
src={src}
alt={alt}
draggable={false}
className="aspect-[3/2] size-full object-cover"
/>
</button>
</li>
);
}
export { ExpandGallery, ExpandGalleryItem };
For AI agents
Hand your agent this page, the markdown at /components/expand-gallery/prompt.md, or the Copy AI prompt button above.
When to use
- A portfolio or case study where each image deserves a full look
- A short set of screenshots that lose their detail at thumbnail size
- When the way back to the grid should be obvious
When not to use
- Long image feeds: every thumbnail loads the full image
- Images that must never crop, because the full view keeps one ratio
- Content that needs its own page or link
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- It uses the native dialog element with showModal, so focus, inert background and Escape come from the browser
- Thumbnails and the full view must share a ratio or the grow stretches. All images are cropped with object-cover
- Set the column span of an item with className. The default grid is two columns, then four from md up
The maths
- The full view is min(100vw - 3rem, (100dvh - 10rem) × ratio) wide, so it fits either way with room for its caption.
- Flip.fit starts the view at thumbnail width over full width, 263 / 960 = 0.27 on a 1280 by 800 screen, and only transform moves.