Lightbox New
A thumbnail grid that opens a viewer with zoom and pan, swipe, arrow keys, a filmstrip, a counter and a details panel.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-lightbox.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/gallery/Lightbox.tsx
import { useEffect, useRef, useState, type PointerEvent as RPointerEvent } from 'react';
import { ChevronLeft, ChevronRight, Info, X, ZoomIn, ZoomOut } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork, type Artwork } from './art';
export type LightboxProps = {
items?: Artwork[];
/** Index opened on mount; omit to start on the grid. */
defaultIndex?: number | null;
className?: string;
};
/** Thumbnail grid with a viewer that zooms, pans, swipes and keeps a filmstrip in sync. */
export function Lightbox({ items = ARTWORKS, defaultIndex = null, className }: LightboxProps) {
const [open, setOpen] = useState<number | null>(defaultIndex);
const [zoom, setZoom] = useState(false);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [info, setInfo] = useState(true);
const [dir, setDir] = useState(1);
const viewer = useRef<HTMLDivElement>(null);
const strip = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; px: number; py: number; moved: boolean } | null>(null);
const go = (d: number) => {
if (open === null) return;
setDir(d);
setZoom(false);
setPan({ x: 0, y: 0 });
setOpen((open + d + items.length) % items.length);
};
const close = () => {
setOpen(null);
setZoom(false);
setPan({ x: 0, y: 0 });
};
useEffect(() => {
if (open === null) return;
viewer.current?.focus({ preventScroll: true });
const el = strip.current?.children[open] as HTMLElement | undefined;
const s = strip.current;
if (el && s) s.scrollTo({ left: el.offsetLeft - s.clientWidth / 2 + el.clientWidth / 2, behavior: 'smooth' });
}, [open]);
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') go(1);
else if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'Escape') close();
else if (e.key === 'z') setZoom((z) => !z);
else return;
e.preventDefault();
};
const down = (e: RPointerEvent) => {
drag.current = { x: e.clientX, y: e.clientY, px: pan.x, py: pan.y, moved: false };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const move = (e: RPointerEvent) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (Math.abs(dx) + Math.abs(dy) > 4) d.moved = true;
if (zoom) setPan({ x: Math.max(-160, Math.min(160, d.px + dx)), y: Math.max(-110, Math.min(110, d.py + dy)) });
};
const up = (e: RPointerEvent) => {
const d = drag.current;
drag.current = null;
if (!d) return;
const dx = e.clientX - d.x;
if (!zoom && d.moved && Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
else if (!d.moved) {
setZoom((z) => !z);
setPan({ x: 0, y: 0 });
}
};
const cur = open === null ? null : items[open];
return (
<div className={cn('relative w-full overflow-hidden rounded-2xl bg-ink-900 p-3 shadow-card sm:p-4', className)} style={{ minHeight: 440 }}>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{items.map((a, i) => (
<button
key={a.id}
onClick={() => setOpen(i)}
aria-label={`Open ${a.title}`}
className="focus-ring group relative aspect-square overflow-hidden rounded-lg bg-ink-850"
>
<img src={artwork(a.id, 320, 320)} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-smooth group-hover:scale-110" />
<span className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
</button>
))}
</div>
{cur && open !== null && (
<div
ref={viewer}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={`${cur.title}, image ${open + 1} of ${items.length}`}
onKeyDown={onKey}
className="absolute inset-0 z-10 flex flex-col bg-black/95 outline-none animate-fade-in"
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5 text-white">
<span className="font-mono text-xs tabular-nums text-white/60">
{String(open + 1).padStart(2, '0')} / {String(items.length).padStart(2, '0')}
</span>
<div className="flex items-center gap-1">
<IconBtn label={zoom ? 'Zoom out' : 'Zoom in'} onClick={() => (setZoom((z) => !z), setPan({ x: 0, y: 0 }))}>
{zoom ? <ZoomOut className="h-4 w-4" /> : <ZoomIn className="h-4 w-4" />}
</IconBtn>
<IconBtn label="Toggle details" on={info} onClick={() => setInfo((v) => !v)}>
<Info className="h-4 w-4" />
</IconBtn>
<IconBtn label="Close" onClick={close}>
<X className="h-4 w-4" />
</IconBtn>
</div>
</div>
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden px-10">
<div
key={open}
className={cn('relative h-full max-h-full w-full touch-none select-none', dir > 0 ? 'animate-[lb-in-r_.45s_cubic-bezier(.16,1,.3,1)]' : 'animate-[lb-in-l_.45s_cubic-bezier(.16,1,.3,1)]')}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={() => (drag.current = null)}
style={{ cursor: zoom ? 'grab' : 'zoom-in' }}
>
<img
src={artwork(cur.id, 960, 640)}
alt={`${cur.title} by ${cur.artist}`}
draggable={false}
className="h-full w-full object-contain transition-transform duration-500 ease-smooth"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom ? 2 : 1})` }}
/>
</div>
<NavBtn side="left" onClick={() => go(-1)} />
<NavBtn side="right" onClick={() => go(1)} />
<div className={cn('absolute bottom-3 left-3 max-w-[70%] rounded-xl border border-white/10 bg-black/60 px-3 py-2 backdrop-blur transition-all duration-300', info ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-2 opacity-0')}>
<p className="truncate text-sm font-medium text-white">{cur.title}</p>
<p className="truncate text-xs text-white/60">
{cur.artist} · {cur.place} · {cur.year}
</p>
</div>
</div>
<div ref={strip} className="flex gap-1.5 overflow-x-auto px-3 py-3 [scrollbar-width:none]">
{items.map((a, i) => (
<button
key={a.id}
onClick={() => (setDir(i > open ? 1 : -1), setOpen(i), setZoom(false), setPan({ x: 0, y: 0 }))}
aria-label={`Show ${a.title}`}
aria-current={i === open}
className={cn('focus-ring relative h-11 w-16 shrink-0 overflow-hidden rounded-md transition-all duration-300', i === open ? 'opacity-100 ring-2 ring-brand-400' : 'opacity-40 hover:opacity-80')}
>
<img src={artwork(a.id, 320, 320)} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
<style>{`@keyframes lb-in-r{from{opacity:0;transform:translateX(40px) scale(.98)}to{opacity:1;transform:none}}@keyframes lb-in-l{from{opacity:0;transform:translateX(-40px) scale(.98)}to{opacity:1;transform:none}}`}</style>
</div>
)}
</div>
);
}
function IconBtn({ label, on, onClick, children }: { label: string; on?: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button onClick={onClick} aria-label={label} aria-pressed={on} className={cn('focus-ring grid h-8 w-8 place-items-center rounded-lg text-white/70 transition-colors hover:bg-white/10 hover:text-white', on && 'bg-white/10 text-white')}>
{children}
</button>
);
}
function NavBtn({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
onClick={onClick}
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
className={cn('focus-ring absolute top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center rounded-full border border-white/10 bg-black/50 text-white backdrop-blur transition-all hover:scale-105 hover:bg-white/15', side === 'left' ? 'left-1.5' : 'right-1.5')}
>
<Icon className="h-4 w-4" />
</button>
);
}
export function LightboxDemo() {
return (
<div className="mx-auto w-full max-w-2xl">
<Lightbox defaultIndex={2} />
</div>
);
}components/niceui/gallery/art.ts
/*
* Procedural artwork for the gallery demos: soft landscapes and abstract mesh
* gradients with film grain, rendered once to data URLs. Swap `src` for real
* photos in your app — every gallery component just takes image URLs.
*/
export type Artwork = { id: number; title: string; artist: string; place: string; year: number; tone: 'warm' | 'cool' | 'dusk' | 'mono' };
const PALETTES: string[][] = [
['#0b1d3a', '#1f4e79', '#f2a65a', '#f7d9a8'],
['#120c26', '#4b2a77', '#e66b9d', '#ffd1b3'],
['#06222b', '#0f5f6b', '#7fd1c7', '#e8f6f1'],
['#1b1410', '#5b3a26', '#d98c4a', '#f6e1c3'],
['#0e0f13', '#2b2f3a', '#8a93a6', '#e6e8ee'],
['#0a1a12', '#1f5134', '#9cc98a', '#f1f7da'],
['#1a0f14', '#6b1f3a', '#ef6f5e', '#ffd8a8'],
['#071426', '#123f73', '#5aa9e6', '#d6ecff'],
['#191325', '#3b2d5c', '#b39ddb', '#f3e9ff'],
['#201608', '#6e4a12', '#e0b34a', '#fff1c4'],
['#0b1216', '#29424f', '#6fa3b8', '#f0e6d2'],
['#170b0b', '#4a1414', '#c84b31', '#f5c26b'],
];
export const ARTWORKS: Artwork[] = [
{ id: 0, title: 'Low Tide, Dawn', artist: 'Mira Okafor', place: 'Comporta', year: 2025, tone: 'warm' },
{ id: 1, title: 'Neon Bloom', artist: 'Ilya Varga', place: 'Kyoto', year: 2024, tone: 'dusk' },
{ id: 2, title: 'Glass Lagoon', artist: 'Sana Iqbal', place: 'Faroe Islands', year: 2025, tone: 'cool' },
{ id: 3, title: 'Copper Dunes', artist: 'Teo Marchetti', place: 'Merzouga', year: 2023, tone: 'warm' },
{ id: 4, title: 'Fog Study No. 4', artist: 'Lena Brandt', place: 'Dolomites', year: 2024, tone: 'mono' },
{ id: 5, title: 'Green Hour', artist: 'Ravi Menon', place: 'Munnar', year: 2025, tone: 'cool' },
{ id: 6, title: 'Ember Coast', artist: 'Noor Haddad', place: 'Big Sur', year: 2024, tone: 'warm' },
{ id: 7, title: 'Blue Hour Harbour', artist: 'Jonas Weber', place: 'Bergen', year: 2025, tone: 'cool' },
{ id: 8, title: 'Lavender Field', artist: 'Camille Roy', place: 'Valensole', year: 2023, tone: 'dusk' },
{ id: 9, title: 'Golden Ridge', artist: 'Aiko Tanaka', place: 'Hokkaido', year: 2025, tone: 'warm' },
{ id: 10, title: 'Salt Flats', artist: 'Diego Alvarez', place: 'Uyuni', year: 2024, tone: 'mono' },
{ id: 11, title: 'Red Canyon', artist: 'Hana Kobayashi', place: 'Arizona', year: 2025, tone: 'warm' },
];
let seedState = 1;
const rand = () => {
seedState = (seedState * 1664525 + 1013904223) >>> 0;
return seedState / 4294967296;
};
const cache = new Map<string, string>();
/** A deterministic artwork as a data URL. `kind` picks a landscape or an abstract mesh. */
export function artwork(id: number, w = 960, h = 640, kind: 'landscape' | 'mesh' = id % 3 === 1 ? 'mesh' : 'landscape'): string {
const key = `${id}-${w}-${h}-${kind}`;
const hit = cache.get(key);
if (hit) return hit;
if (typeof document === 'undefined') return '';
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
if (!g) return '';
seedState = (id + 1) * 9973;
const [deep, mid, hot, light] = PALETTES[id % PALETTES.length];
const sky = g.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, deep);
sky.addColorStop(0.55, mid);
sky.addColorStop(1, kind === 'mesh' ? deep : hot);
g.fillStyle = sky;
g.fillRect(0, 0, w, h);
// soft light blobs
const blobs = kind === 'mesh' ? 6 : 3;
for (let i = 0; i < blobs; i++) {
const x = rand() * w;
const y = rand() * h * (kind === 'mesh' ? 1 : 0.6);
const r = (0.25 + rand() * 0.45) * Math.max(w, h);
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, [hot, light, mid][i % 3] + 'cc');
rg.addColorStop(1, 'transparent');
g.globalCompositeOperation = 'screen';
g.fillStyle = rg;
g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
if (kind === 'landscape') {
// sun
const sx = (0.25 + rand() * 0.5) * w;
const sy = h * (0.38 + rand() * 0.12);
const sr = Math.min(w, h) * (0.07 + rand() * 0.05);
const glow = g.createRadialGradient(sx, sy, 0, sx, sy, sr * 5);
glow.addColorStop(0, light + 'aa');
glow.addColorStop(1, 'transparent');
g.fillStyle = glow;
g.fillRect(0, 0, w, h);
g.fillStyle = light;
g.beginPath();
g.arc(sx, sy, sr, 0, Math.PI * 2);
g.fill();
// ridges, back to front
for (let layer = 0; layer < 4; layer++) {
const base = h * (0.5 + layer * 0.12);
const amp = h * (0.12 - layer * 0.02);
const f1 = 1.5 + rand() * 2;
const f2 = 4 + rand() * 4;
const ph = rand() * 10;
g.beginPath();
g.moveTo(0, h);
for (let x = 0; x <= w; x += 4) {
const u = x / w;
const y = base - amp * (0.6 * Math.sin(u * f1 * Math.PI + ph) + 0.3 * Math.sin(u * f2 * Math.PI + ph * 2) + 0.1 * Math.sin(u * 23 + ph));
g.lineTo(x, y);
}
g.lineTo(w, h);
g.closePath();
const t = layer / 3;
const lg = g.createLinearGradient(0, base - amp, 0, h);
lg.addColorStop(0, mix(mid, deep, 0.25 + t * 0.6));
lg.addColorStop(1, mix(deep, '#000000', t * 0.5));
g.fillStyle = lg;
g.globalAlpha = 0.92;
g.fill();
g.globalAlpha = 1;
}
} else {
// flowing ribbons
for (let i = 0; i < 3; i++) {
g.beginPath();
const y0 = h * (0.2 + rand() * 0.6);
g.moveTo(-50, y0);
g.bezierCurveTo(w * 0.3, y0 - h * (rand() - 0.5), w * 0.7, y0 + h * (rand() - 0.5), w + 50, y0 + h * (rand() - 0.5) * 0.6);
g.lineWidth = Math.min(w, h) * (0.08 + rand() * 0.12);
g.strokeStyle = [light, hot, mid][i] + '55';
g.filter = `blur(${Math.round(Math.min(w, h) * 0.04)}px)`;
g.stroke();
g.filter = 'none';
}
}
// vignette
const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.45)');
g.fillStyle = vg;
g.fillRect(0, 0, w, h);
// film grain
const img = g.getImageData(0, 0, w, h);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (rand() - 0.5) * 18;
d[i] += n;
d[i + 1] += n;
d[i + 2] += n;
}
g.putImageData(img, 0, 0);
const url = c.toDataURL('image/jpeg', 0.86);
cache.set(key, url);
return url;
}
function mix(a: string, b: string, t: number) {
const pa = parseInt(a.slice(1), 16);
const pb = parseInt(b.slice(1), 16);
const ch = (s: number) => Math.round(((pa >> s) & 255) * (1 - t) + ((pb >> s) & 255) * t);
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}