A headless React lightbox with native-feeling gestures. Built on Base UI and Motion. Ships no CSS.
Install
pnpm add @frontendsupply/lightbox @base-ui/react motion
react and react-dom 19+ are peer dependencies too. Swiping is native scroll-snap; mouse dragging comes from
Blossom Carousel (a dependency, with its small stylesheet imported for you).
Your first lightbox
Give Root your items, wrap each thumbnail in a Trigger, and render the overlay. Viewport without children renders one
zoomable slide per item.
import { Lightbox } from '@frontendsupply/lightbox'
import { landscapes } from '../../data/photos'
const photos = landscapes.slice(0, 6)
export default function Basic() {
return (
<Lightbox.Root items={photos}>
<div className="grid grid-cols-3 gap-2">
{photos.map((photo, i) => (
<Lightbox.Trigger
key={photo.src}
index={i}
className="overflow-hidden rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
<img src={photo.thumb} alt={photo.alt} className="aspect-square size-full object-cover" />
</Lightbox.Trigger>
))}
</div>
<Lightbox.Portal>
<Lightbox.Backdrop className="z-50 bg-black" />
<Lightbox.Popup className="z-50 outline-none">
<Lightbox.Viewport />
<Lightbox.Close className="absolute top-4 right-4 rounded-full bg-white/15 px-4 py-1.5 text-sm text-white backdrop-blur">
Close
</Lightbox.Close>
</Lightbox.Popup>
</Lightbox.Portal>
</Lightbox.Root>
)
}
That’s the whole API surface for the common case. Everything else — counters, captions, thumbnails, arrows — is your own UI
reading useLightbox().
Anatomy
<Lightbox.Root items={items}>
<Lightbox.Trigger index={0} /> {/* as many as you like, several per index is fine */}
<Lightbox.Portal>
<Lightbox.Backdrop />
<Lightbox.Popup>
<Lightbox.Viewport> {/* native scroll-snap scroller */}
<Lightbox.Slide index={0}> {/* padding here = breathing room around the image */}
<Lightbox.Image /> {/* the morph + zoom target */}
</Lightbox.Slide>
</Lightbox.Viewport>
<Lightbox.Close />
<Lightbox.Prev />
<Lightbox.Next />
</Lightbox.Popup>
</Lightbox.Portal>
</Lightbox.Root>
Portal, Backdrop, Popup, Close, Title and Description are Base UI Dialog
parts, so you get focus trapping, scroll locking, Esc to close and focus return for free.
Tips
- Pass
widthandheighton items when you know them. The viewer can then lay out before the full image loads. Without them it falls back to the thumbnail’s natural size. - Thumbnails are placeholders. While the full image downloads, the viewer shows the trigger’s
<img>— already in cache — in its place, so the morph never shows an empty box. - The morph reads your CSS.
object-fit,object-positionandborder-radiusof the thumbnail are measured at open and close time. Change them freely. - Next steps: Gestures, Styling, the API reference, or steal an example: Feed, Listing, Stories.