Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
rationumber1.5On ExpandGallery. Width over height of the full view. Match your thumbnails.
srcstringOn ExpandGalleryItem. The image, used for the thumbnail and the full view.
altstringOn ExpandGalleryItem. Also names the button that opens it.
titleReactNodeOn ExpandGalleryItem. Shown under the full view, and names the dialog.
captionReactNodeOn 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.