Lightbox Gallery
A thumbnail grid that opens a lightbox growing from the tapped tile; swipe or use arrows, Escape returns to the grid.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.json'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import './lightbox-gallery.css'
export interface LightboxItem { id: string; label: string; content?: ReactNode }
export interface LightboxGalleryProps {
items: LightboxItem[]
columns?: number
fill?: 'dark' | 'light'
className?: string
}
/** Thumbnail grid with a lightbox that grows from the tapped tile. */
export function LightboxGallery({ items, columns = 3, fill = 'dark', className }: LightboxGalleryProps) {
const [open, setOpen] = useState<number | null>(null)
const [dir, setDir] = useState<'open' | 'next' | 'prev'>('open')
const [origin, setOrigin] = useState({ tx: 0, ty: 0, s: 0.3 })
const root = useRef<HTMLDivElement>(null)
const dlg = useRef<HTMLDivElement>(null)
const thumbs = useRef<(HTMLButtonElement | null)[]>([])
const swipe = useRef<number | null>(null)
useEffect(() => { if (open !== null) dlg.current?.focus() }, [open])
const show = (i: number) => {
const t = thumbs.current[i]?.getBoundingClientRect(), r = root.current?.getBoundingClientRect()
if (t && r) {
const stageW = r.width - 28, stageH = r.height - 110
const s = t.width / stageW
setOrigin({ tx: t.left - r.left - 14 - (stageW - t.width) / 2 + (stageW * (1 - s)) / 2 - (stageW * (1 - s)) / 2, ty: t.top - r.top - 54 - (stageH - t.height) / 2, s })
}
setDir('open'); setOpen(i)
}
const go = (d: 1 | -1) => setOpen((o) => { if (o === null) return o; const n = o + d; if (n < 0 || n >= items.length) return o; setDir(d > 0 ? 'next' : 'prev'); return n })
const close = () => { const i = open; setOpen(null); if (i !== null) requestAnimationFrame(() => thumbs.current[i]?.focus()) }
return (
<div ref={root} className={['sd-lbx', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-lbx__grid" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{items.map((it, i) => <button key={it.id} ref={(el) => { thumbs.current[i] = el }} type="button" className="sd-lbx__th" aria-label={`Open ${it.label}`} onClick={() => show(i)}>{it.content ?? i + 1}</button>)}
</div>
{open !== null && (
<div
ref={dlg}
className="sd-lbx__dlg"
role="dialog"
aria-modal="true"
aria-label={items[open].label}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') close()
if (e.key === 'ArrowRight') go(1)
if (e.key === 'ArrowLeft') go(-1)
if (e.key === 'Tab') { const f = dlg.current!.querySelectorAll<HTMLElement>('button:not(:disabled)'); if (!f.length) return; const a = f[0], z = f[f.length - 1]; if (e.shiftKey && document.activeElement === a) { e.preventDefault(); z.focus() } else if (!e.shiftKey && document.activeElement === z) { e.preventDefault(); a.focus() } }
}}
>
<div className="sd-lbx__top"><span aria-live="polite">{open + 1} / {items.length}</span><button type="button" className="sd-lbx__close" aria-label="Close" onClick={close}><svg viewBox="0 0 12 12" aria-hidden><path d="m3 3 6 6M9 3 3 9" /></svg></button></div>
<div className="sd-lbx__stage" onPointerDown={(e) => { swipe.current = e.clientX }} onPointerUp={(e) => { if (swipe.current === null) return; const dx = e.clientX - swipe.current; swipe.current = null; if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1) }}>
<button type="button" className="sd-lbx__nav" style={{ left: 20 }} aria-label="Previous" disabled={open === 0} onClick={() => go(-1)}><svg viewBox="0 0 12 12" aria-hidden><path d="M7.5 2.5 4 6l3.5 3.5" /></svg></button>
<div key={open} className="sd-lbx__slide" data-open={dir === 'open' || undefined} data-dir={dir !== 'open' ? dir : undefined} style={{ '--tx': `${origin.tx}px`, '--ty': `${origin.ty}px`, '--s': origin.s } as React.CSSProperties}>{items[open].content ?? open + 1}</div>
<button type="button" className="sd-lbx__nav" style={{ right: 20 }} aria-label="Next" disabled={open === items.length - 1} onClick={() => go(1)}><svg viewBox="0 0 12 12" aria-hidden><path d="M4.5 2.5 8 6 4.5 9.5" /></svg></button>
</div>
<div className="sd-lbx__cap">{items[open].label}</div>
</div>
)}
</div>
)
}.sd-lbx { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lbx[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lbx button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lbx :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lbx .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lbx { position: relative; width: min(100%, 330px); height: 380px; padding: 10px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-lbx__grid { display: grid; gap: 6px; }
.sd-lbx__th { aspect-ratio: 1; border-radius: 10px; background: var(--soft); display: grid; place-items: center; font: 600 18px/1 ui-sans-serif, system-ui; color: var(--mute); transition: transform 150ms, box-shadow 150ms; }
.sd-lbx__th:hover { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-lbx__th:active { transform: scale(0.96); }
.sd-lbx__dlg { position: absolute; inset: 0; z-index: 5; display: flex; flex-direction: column; background: var(--bg); outline: 0; animation: sd-lbx-fade 200ms ease; }
@keyframes sd-lbx-fade { from { background: transparent; } }
.sd-lbx__top { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; font-size: 12.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-lbx__close { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 99px; background: var(--soft) !important; }
.sd-lbx__close svg, .sd-lbx__nav svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sd-lbx__stage { position: relative; flex: 1; touch-action: pan-y; }
.sd-lbx__slide { position: absolute; inset: 0 14px; border-radius: 14px; background: var(--soft); display: grid; place-items: center; font: 600 56px/1 ui-sans-serif, system-ui; color: var(--mute); transform-origin: var(--ox) var(--oy); }
.sd-lbx__slide[data-open] { animation: sd-lbx-grow 340ms cubic-bezier(0.2, 1, 0.3, 1); }
@keyframes sd-lbx-grow { from { transform: translate(var(--tx), var(--ty)) scale(var(--s)); border-radius: 30px; } }
.sd-lbx__slide[data-dir='next'] { animation: sd-lbx-n 280ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-lbx__slide[data-dir='prev'] { animation: sd-lbx-p 280ms cubic-bezier(0.2, 1, 0.3, 1); }
@keyframes sd-lbx-n { from { transform: translateX(30%); opacity: 0; } }
@keyframes sd-lbx-p { from { transform: translateX(-30%); opacity: 0; } }
.sd-lbx__nav { position: absolute; top: 50%; display: grid; place-items: center; width: 34px; height: 34px; margin-top: -17px; border-radius: 99px; background: var(--bg) !important; box-shadow: inset 0 0 0 1px var(--line); z-index: 1; }
.sd-lbx__nav:disabled { opacity: 0.3; }
.sd-lbx__cap { padding: 12px 14px 14px; font-size: 13.5px; font-weight: 600; }
@media (prefers-reduced-motion: reduce) { .sd-lbx *, .sd-lbx *::before, .sd-lbx *::after { animation: none !important; transition: none !important; } }Overview
The lightbox animates out of the exact thumbnail you tapped and back into it on close, so people keep their place. Inside, swipe, arrow keys or the side buttons move between items, with a counter.
Works with images or any content per slide; the demo uses monochrome placeholder tiles.
When to use
- Product photos, portfolios, attachments.
When not to use
- A single image: open it inline.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.json$
npx shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy lightbox-gallery.tsx and lightbox-gallery.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LightboxGallery } from '@/components/sundae/lightbox-gallery'
<LightboxGallery />Props
| Prop | Type | Default |
|---|---|---|
items*Slides. | { id: string; label: string; content?: ReactNode }[] | — |
columnsGrid columns. | number | 3 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The lightbox is a modal dialog: focus moves in, is trapped, and returns to the thumbnail on close.
- Arrow keys navigate; the counter is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lbx; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next
- Esc
- Close
FAQ
- Is Lightbox Gallery free?
- Yes. Lightbox Gallery is free for personal and commercial projects, with full source on this page.
- How do I install Lightbox Gallery?
- Run npx shadcn@latest add https://sundaeui.dev/r/lightbox-gallery.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Lightbox Gallery support keyboard and screen readers?
- The lightbox is a modal dialog: focus moves in, is trapped, and returns to the thumbnail on close. Arrow keys navigate; the counter is announced.
- Does Lightbox Gallery respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Swipe Actions
Components · Refined Snap Carousel
Components · RefinedSlide to Confirm
Components · RefinedSelectable Grid
Components · RefinedPodcast Chapters
Components · Refined- Pro
Glass Control Center
Components · Refined