Lightbox
betaPhotos and videos one at a time, over everything, dark in both themes like a photo viewer, with your photo consent rule applied.
Preview
open
Use ← and → or swipe to move. Esc, Close, or a press on the space around the photo closes it.
inline
Photos from the team offsite
Tuesday workshop
With Josh Tait, Mia Okafor and Sam Carter
manage
Photos from the team offsite
Summer picnic
With Josh Tait and Ella Clarke
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/lightbox
Use it for
- Viewing a set of photos or videos large, one at a time.
- Open it from a photo's own button, and pass open and onOpenChange. It has no trigger of its own.
- Name the set with label, like 'Photos from the team offsite'. Screen readers hear it as it opens.
- Pass manage only to people who manage your media. They see who has no consent on file, and why a photo is hidden.
- Use MediaGrid for a grid of tiles that opens it, with focus back on the photo you were looking at.
Avoid it for
- One photo that never needs to be seen larger. Use MediaImage.
- A gallery of documents or files. It is for photos and videos of people.
- Actions that change many photos. Its actions are for the photo shown.
Props
- items
Required. The photos and videos, in order. See MediaImage for MediaItem.
MediaItem[]
- index
The photo it opens on. While it is open, it follows a new index.
number · default 0
- open
Controlled: shown while true. Pair it with onOpenChange.
boolean
- defaultOpen
Opens on mount, like a link to one photo.
boolean · default false
- onOpenChange
It opened or closed, with the photo it was on. It closes on Esc, Close, and a press on the space around the photo.
(open: boolean, index: number) => void
- onClose
It closed by Esc or Close, with the photo it was on.
(index: number) => void
- onIndexChange
It moved to another photo.
(index: number) => void
- manage
For people who manage your media: names in consent notes, and why a photo is hidden.
boolean · default false
- actions
Icon buttons beside Close for the photo shown, like Download, Share, and Delete.
(item: MediaItem) => { icon: ReactNode; label: string; onClick: () => void }[]
- label
The set's name. It titles the viewer and is read as it opens.
string · default "Photos"
- photoConsent
Your consent rule. See MediaImage.
{ who?: "everyone" | "juniors" | "nobody"; missing?: "blur" | "hide" } · default { who: "everyone", missing: "blur" }
- returnFocus
Where focus goes as it closes, given the photo it was on. By default it goes back where it was.
(index: number) => HTMLElement | null | undefined
- inline
Draws it in the page's flow, with no backdrop and no Close, like a picture of the viewer in a doc.
boolean · default false
- className
Extra classes on the viewer. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| items | MediaItem[] | - | Required. The photos and videos, in order. See MediaImage for MediaItem. |
| index | number | 0 | The photo it opens on. While it is open, it follows a new index. |
| open | boolean | - | Controlled: shown while true. Pair it with onOpenChange. |
| defaultOpen | boolean | false | Opens on mount, like a link to one photo. |
| onOpenChange | (open: boolean, index: number) => void | - | It opened or closed, with the photo it was on. It closes on Esc, Close, and a press on the space around the photo. |
| onClose | (index: number) => void | - | It closed by Esc or Close, with the photo it was on. |
| onIndexChange | (index: number) => void | - | It moved to another photo. |
| manage | boolean | false | For people who manage your media: names in consent notes, and why a photo is hidden. |
| actions | (item: MediaItem) => { icon: ReactNode; label: string; onClick: () => void }[] | - | Icon buttons beside Close for the photo shown, like Download, Share, and Delete. |
| label | string | "Photos" | The set's name. It titles the viewer and is read as it opens. |
| photoConsent | { who?: "everyone" | "juniors" | "nobody"; missing?: "blur" | "hide" } | { who: "everyone", missing: "blur" } | Your consent rule. See MediaImage. |
| returnFocus | (index: number) => HTMLElement | null | undefined | - | Where focus goes as it closes, given the photo it was on. By default it goes back where it was. |
| inline | boolean | false | Draws it in the page's flow, with no backdrop and no Close, like a picture of the viewer in a doc. |
| className | string | - | Extra classes on the viewer. Later classes win. |
Accessibility
- A modal dialog named by label. The page behind is inert and does not scroll, and Tab stays inside, even when pressed fast.
- As it opens, focus moves onto the viewer itself, so its name is read. A page that does not have focus keeps it where it is.
- ← and → move between photos without wrapping, and Home and End go to the ends. Right to left, the arrows swap. Each move is said in a polite live region, like '2 of 5. Launch'.
- Previous and Next stay focusable at the ends, so focus is never lost when one stops moving.
- Esc closes it, even from Close's tooltip, which teaches the key. Focus goes back where it was, or to returnFocus.
- The caption names only people with nothing held back. Consent notes are words, not only a blur.
Examples
From a photo's button
const [open, setOpen] = useState(false);
<button type="button" onClick={() => setOpen(true)} aria-label="View photos from the launch">
<MediaImage item={photos[0]} alt="" className="size-32" />
</button>
<Lightbox items={photos} open={open} onOpenChange={setOpen} label="Photos from the launch" />With actions, for a media manager
import { Download, Trash2 } from "lucide-react";
<Lightbox
items={photos}
open={open}
onOpenChange={setOpen}
manage
actions={(photo) => [
{ icon: <Download />, label: "Download", onClick: () => download(photo) },
{ icon: <Trash2 />, label: "Delete", onClick: () => remove(photo) },
]}
/>