Skip to content
goodtabs UI
BrowseBrowse components

Image lightbox

Thumbnails that open a full image in a dialog with arrow key navigation.

npx shadcn@latest add https://ui.goodtabs.co/r/image-lightbox.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-lightbox

A thumbnail strip. Click one, then use the arrow keys.

Gallery

Six images in two rows, wrapping around at the ends.

Usage

import { ImageLightbox } from "@/components/ui/image-lightbox"

<ImageLightbox images={[{ src: "/a.jpg", alt: "Dashboard in dark mode" }]} />

Props

PropTypeDefaultDescription
images{ src: string; alt: string; caption?: string }[]The images in order. Alt text is required.

Source

image-lightbox/image-lightbox.tsx
"use client";

import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export type LightboxImage = {
  src: string;
  alt: string;
  caption?: string;
};

type ImageLightboxProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The images, in order. Thumbnails and the full view share this list. */
  images: LightboxImage[];
};

/**
 * A grid of thumbnails that opens one image in a real dialog. Left and right
 * arrow keys, or the two buttons, move between images. Escape closes it and
 * focus returns to the thumbnail you opened.
 */
export function ImageLightbox({ images, className, ...props }: ImageLightboxProps) {
  const [index, setIndex] = React.useState(0);
  const count = images.length;
  const current = images[index];

  const step = (delta: number) => setIndex((i) => (i + delta + count) % count);
  // Radix returns focus to the last trigger it registered, not the one that opened the dialog.
  const opener = React.useRef<HTMLButtonElement | null>(null);

  function onKeyDown(event: React.KeyboardEvent) {
    if (event.key === "ArrowLeft") {
      event.preventDefault();
      step(-1);
    } else if (event.key === "ArrowRight") {
      event.preventDefault();
      step(1);
    }
  }

  return (
    <Dialog>
      <div data-slot="image-lightbox" className={cn("grid grid-cols-3 gap-2", className)} {...props}>
        {images.map((image, i) => (
          <DialogTrigger key={image.src} asChild>
            <button
              type="button"
              onClick={(event) => {
                opener.current = event.currentTarget;
                setIndex(i);
              }}
              className="group aspect-[3/2] overflow-hidden rounded-lg border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={image.src}
                alt={image.alt}
                loading="lazy"
                className="size-full object-cover transition-transform duration-200 ease-out group-hover:scale-[1.03] motion-reduce:transition-none"
              />
            </button>
          </DialogTrigger>
        ))}
      </div>
      <DialogContent
        showCloseButton
        onKeyDown={onKeyDown}
        onCloseAutoFocus={(event) => {
          event.preventDefault();
          opener.current?.focus();
        }}
        className="gap-3 p-3 sm:max-w-3xl"
      >
        <DialogTitle className="sr-only">
          Image {index + 1} of {count}
        </DialogTitle>
        <DialogDescription className="sr-only">Use the left and right arrow keys to change image.</DialogDescription>
        <div className="relative overflow-hidden rounded-lg bg-muted">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img src={current.src} alt={current.alt} className="aspect-[3/2] w-full object-cover" />
          {count > 1 && (
            <>
              <Button
                variant="secondary"
                size="icon-sm"
                className="absolute top-1/2 left-2 -translate-y-1/2 rounded-full"
                onClick={() => step(-1)}
              >
                <ChevronLeftIcon />
                <span className="sr-only">Previous image</span>
              </Button>
              <Button
                variant="secondary"
                size="icon-sm"
                className="absolute top-1/2 right-2 -translate-y-1/2 rounded-full"
                onClick={() => step(1)}
              >
                <ChevronRightIcon />
                <span className="sr-only">Next image</span>
              </Button>
            </>
          )}
        </div>
        <div className="flex items-baseline justify-between gap-4 pr-8 text-sm">
          <p className="min-w-0 truncate text-foreground/70">{current.caption ?? current.alt}</p>
          <p aria-live="polite" className="shrink-0 font-mono text-xs text-foreground/70">
            {index + 1} / {count}
          </p>
        </div>
      </DialogContent>
    </Dialog>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/image-lightbox/prompt.md, or the Copy AI prompt button above.

When to use

  • Screenshots or work samples that need a larger look without leaving the page
  • Small sets of images where a caption and a position count are enough

When not to use

  • Large galleries that need zoom, pan or thumbnails inside the viewer
  • A single decorative image that nobody needs to enlarge

Agent notes

  • Built on the shadcn Dialog, so focus is trapped, Escape closes and focus returns to the thumbnail
  • Left and right arrows move between images and wrap; the buttons do the same for pointer users
  • Thumbnails and the full view use plain img tags; swap in next/image in your own project if you prefer
  • The dialog title is screen reader only and reads Image 2 of 6, the caption is visible