Skip to content

Lightbox

Data Display

A responsive gallery with modal image navigation, zoom, captions, and focus return.

Three-image studio collectionTwo-column favoritesModal next/previousZoom and keyboard scrollingImage load failure
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

Lightbox props
PropType / DefaultDescription
labelstring

Default: required

Accessible gallery and modal title.
imagesLightboxImage[]

Default: required

Unique id, src, meaningful alt, and optional caption.
columns2 | 3

Default: 3

Responsive thumbnail columns; three becomes two on small screens.
classNamestring

Default: —

Extend the container with semantic theme classes.

Build with these too