# Lightbox

> Thumbnail grid with a full-screen viewer, previous/next, counter, arrow keys and Esc.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/lightbox
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/lightbox.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/lightbox-demo.tsx"
"use client"

import { Lightbox, type LightboxImage } from "@/components/lightbox"

function art(from: string, to: string, shape: string) {
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="1200" height="800" fill="url(#g)"/>${shape}</svg>`
  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}

const circle = '<circle cx="860" cy="260" r="150" fill="rgba(255,255,255,.25)"/><circle cx="360" cy="560" r="220" fill="rgba(255,255,255,.14)"/>'
const waves = '<path d="M0 560 C200 460 400 660 600 560 S1000 460 1200 560 V800 H0Z" fill="rgba(255,255,255,.22)"/><path d="M0 640 C200 540 400 740 600 640 S1000 540 1200 640 V800 H0Z" fill="rgba(255,255,255,.16)"/>'
const grid = '<g stroke="rgba(255,255,255,.28)" stroke-width="2">' + Array.from({ length: 9 }, (_, i) => `<path d="M${i * 150} 0V800M0 ${i * 100}H1200"/>`).join("") + "</g>"

const images: LightboxImage[] = [
  { src: art("#6366f1", "#ec4899", circle), alt: "Violet to pink gradient with soft circles", caption: "Dusk — gradient study 01" },
  { src: art("#06b6d4", "#3b82f6", waves), alt: "Cyan to blue gradient with waves", caption: "Tide — gradient study 02" },
  { src: art("#f59e0b", "#ef4444", grid), alt: "Amber to red gradient with a grid", caption: "Ember — gradient study 03" },
  { src: art("#10b981", "#14b8a6", waves), alt: "Green gradient with waves", caption: "Canopy — gradient study 04" },
  { src: art("#8b5cf6", "#06b6d4", circle), alt: "Purple to cyan gradient with circles", caption: "Aurora — gradient study 05" },
  { src: art("#f43f5e", "#f59e0b", grid), alt: "Rose to amber gradient with a grid", caption: "Sunset — gradient study 06" },
]

export default function LightboxDemo() {
  return <Lightbox images={images} columns={3} className="w-full max-w-lg" />
}
```

## Source

```tsx title="components/lightbox.tsx"
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, X } from "lucide-react"

import { cn } from "@/lib/utils"

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

export interface LightboxProps extends Omit<React.ComponentProps<"div">, "children"> {
  images: LightboxImage[]
  columns?: 2 | 3 | 4
}

const cols = { 2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4" } as const
const control =
  "flex size-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"

export function Lightbox({ images, columns = 3, className, ...props }: LightboxProps) {
  const [index, setIndex] = React.useState<number | null>(null)
  const ref = React.useRef<HTMLDialogElement>(null)
  const open = index !== null
  const current = index === null ? null : images[index]

  React.useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    else if (!open && dialog.open) dialog.close()
  }, [open])

  const go = (step: number) => setIndex((value) => (value === null ? value : (value + step + images.length) % images.length))

  return (
    <div className={className} {...props}>
      <div className={cn("grid gap-2", cols[columns])}>
        {images.map((image, i) => (
          <button
            key={image.src}
            type="button"
            onClick={() => setIndex(i)}
            aria-label={`Open ${image.alt}`}
            className="group overflow-hidden rounded-xl border focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img src={image.src} alt={image.alt} className="aspect-[4/3] w-full object-cover transition-transform duration-300 group-hover:scale-105" />
          </button>
        ))}
      </div>

      <dialog
        ref={ref}
        aria-label="Image viewer"
        onCancel={(event) => {
          event.preventDefault()
          setIndex(null)
        }}
        onKeyDown={(event) => {
          if (event.key === "ArrowRight") go(1)
          else if (event.key === "ArrowLeft") go(-1)
        }}
        className={cn(
          "fixed inset-0 m-0 size-full max-h-none max-w-none bg-transparent p-0 text-white opacity-0",
          "transition-[opacity,display,overlay] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0",
          "backdrop:bg-black/0 backdrop:transition-[background-color,display,overlay] backdrop:transition-discrete backdrop:duration-200 open:backdrop:bg-black/90 starting:open:backdrop:bg-black/0"
        )}
      >
        <div
          className="flex size-full flex-col items-center justify-center gap-4 p-4"
          onClick={(event) => {
            if (event.target === event.currentTarget) setIndex(null)
          }}
        >
          <div className="absolute top-4 right-4 left-4 flex items-center justify-between">
            <span className="rounded-full bg-white/10 px-3 py-1 text-sm tabular-nums backdrop-blur">
              {(index ?? 0) + 1} / {images.length}
            </span>
            <button type="button" aria-label="Close" onClick={() => setIndex(null)} className={control}>
              <X className="size-5" />
            </button>
          </div>
          {images.length > 1 && (
            <>
              <button type="button" aria-label="Previous image" onClick={() => go(-1)} className={cn(control, "absolute left-4 top-1/2 -translate-y-1/2")}>
                <ChevronLeft className="size-5" />
              </button>
              <button type="button" aria-label="Next image" onClick={() => go(1)} className={cn(control, "absolute right-4 top-1/2 -translate-y-1/2")}>
                <ChevronRight className="size-5" />
              </button>
            </>
          )}
          {current && (
            <figure key={current.src} className="flex animate-in flex-col items-center gap-3 duration-200 fade-in zoom-in-95">
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={current.src} alt={current.alt} className="max-h-[78vh] max-w-[88vw] rounded-lg object-contain shadow-2xl" />
              {current.caption && <figcaption className="text-sm text-white/80">{current.caption}</figcaption>}
            </figure>
          )}
        </div>
      </dialog>
    </div>
  )
}
```
