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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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