Lightbox
Data DisplayA responsive gallery with modal image navigation, zoom, captions, and focus return.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Lightbox } from "@/components/ui/lightbox";
const images = [
{
id: "coast",
src: "/images/component-demos/coast.svg",
alt: "Layered ocean waves beneath a soft coastal sky",
caption: "The coastal collection",
},
{
id: "summit",
src: "/images/component-demos/summit.svg",
alt: "Mountain peaks with a warm sun and snowy summit",
caption: "Find your view",
},
{
id: "desert",
src: "/images/component-demos/desert.svg",
alt: "Sand dunes beneath a terracotta sun",
caption: "Room to breathe",
},
];
export default function LightboxExample() {
return (
<div className="w-full max-w-2xl space-y-7">
<div>
<h3 className="font-semibold">A change of perspective</h3>
<p className="mt-1 text-sm text-muted-foreground">
Open an illustration to explore. Use arrow keys to move between
images.
</p>
</div>
<Lightbox label="Studio collection" images={images} />
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
TWO-COLUMN GALLERY
</p>
<Lightbox
label="Selected favorites"
images={images.slice(0, 2)}
columns={2}
/>
</div>
<p className="text-xs text-muted-foreground">
Original local SVG illustrations. No external image requests.
</p>
</div>
);
}Usage
Provide unique ids, image sources, descriptive alt text, and optional captions. Thumbnails open a Radix modal with wraparound previous/next navigation. Arrow keys follow document direction, Escape closes, and zoom switches to a double-width scrollable image. The component restores focus to the thumbnail that opened it. External sources load directly through unoptimized Next Image; use trusted origins and suitable asset sizes. Empty galleries and failed full-size images show explicit messages.
import { Lightbox } from "@/components/ui/lightbox";Shipped as editable source in src/components/ui/lightbox.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/lightbox.tsx
"use client";
import { useId, useRef, useState } from "react";
import { Dialog } from "radix-ui";
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from "lucide-react";
import NextImage from "next/image";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { useDocumentDirection } from "@/lib/use-document-direction";
export interface LightboxImage {
id: string;
src: string;
alt: string;
caption?: string;
}
export interface LightboxProps {
label: string;
images: LightboxImage[];
columns?: 2 | 3;
className?: string;
}
export function Lightbox({
label,
images,
columns = 3,
className,
}: LightboxProps) {
const id = useId();
const trigger = useRef<HTMLButtonElement | null>(null);
const [index, setIndex] = useState(0);
const [open, setOpen] = useState(false);
const [zoom, setZoom] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
const rtl = useDocumentDirection() === "rtl";
const current = Math.min(index, Math.max(0, images.length - 1));
const image = images[current];
const move = (amount: number) => {
setIndex((current + amount + images.length) % images.length);
setZoom(false);
setFailed(null);
};
if (!images.length)
return (
<p className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
No images available.
</p>
);
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
setZoom(false);
}}
>
<div
className={cn(
"grid gap-3",
columns === 2 ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-3",
className,
)}
role="group"
aria-label={label}
>
{images.map((item, position) => (
<Dialog.Trigger key={item.id} asChild>
<button
type="button"
aria-label={`Open ${item.caption ?? item.alt}: ${item.alt}`}
onClick={(event) => {
trigger.current = event.currentTarget;
setIndex(position);
setFailed(null);
}}
className="group relative aspect-[4/3] overflow-hidden rounded-xl border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<NextImage
unoptimized
fill
sizes="(max-width: 640px) 50vw, 33vw"
src={item.src}
alt=""
loading="lazy"
className="size-full object-cover transition-transform group-hover:scale-105 motion-reduce:transition-none"
/>
<span className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-3 py-2 text-start text-xs text-white">
{item.caption ?? item.alt}
</span>
</button>
</Dialog.Trigger>
))}
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80" />
<Dialog.Content
aria-describedby={`${id}-description`}
onCloseAutoFocus={(event) => {
event.preventDefault();
trigger.current?.focus();
}}
onKeyDown={(event) => {
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
move((event.key === "ArrowRight") !== rtl ? 1 : -1);
}
}}
className="fixed inset-4 z-50 flex flex-col rounded-xl border bg-background p-4 text-foreground shadow-xl outline-none sm:inset-8"
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Dialog.Title className="text-sm font-semibold">
{label}
</Dialog.Title>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={zoom ? "Fit image" : "Zoom image"}
onClick={() => setZoom(!zoom)}
>
{zoom ? (
<ZoomOut className="size-4" />
) : (
<ZoomIn className="size-4" />
)}
</Button>
<Dialog.Close asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close image viewer"
>
<X className="size-4" />
</Button>
</Dialog.Close>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto rounded-lg bg-muted/30"
tabIndex={0}
role="region"
aria-label="Image canvas"
>
{failed === image.src ? (
<p role="status" className="p-8 text-sm text-muted-foreground">
Image could not be loaded. Use the navigation controls to view
another.
</p>
) : (
<NextImage
unoptimized
width={1600}
height={1000}
key={image.src}
src={image.src}
alt={image.alt}
onError={() => setFailed(image.src)}
className={cn(
zoom
? "h-auto w-[200%] max-w-none"
: "size-full object-contain",
)}
/>
)}
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<Dialog.Description id={`${id}-description`} className="text-sm">
{image.caption ?? image.alt}
</Dialog.Description>
<p role="status" className="mt-1 text-xs text-muted-foreground">
Image {current + 1} of {images.length}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="icon"
aria-label="Previous image"
disabled={images.length < 2}
onClick={() => move(-1)}
>
<ChevronLeft className="size-4 rtl:rotate-180" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Next image"
disabled={images.length < 2}
onClick={() => move(1)}
>
<ChevronRight className="size-4 rtl:rotate-180" />
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Required helper: src/lib/use-document-direction.ts
Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.
"use client";
import { useSyncExternalStore } from "react";
function subscribe(notify: () => void) {
const observer = new MutationObserver(notify);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
});
return () => observer.disconnect();
}
function snapshot(): "ltr" | "rtl" {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}
/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible gallery and modal title. |
| images | LightboxImage[]Default: required | Unique id, src, meaningful alt, and optional caption. |
| columns | 2 | 3Default: 3 | Responsive thumbnail columns; three becomes two on small screens. |
| className | stringDefault: — | Extend the container with semantic theme classes. |