Skip to content
txbrendan/ui

Lightbox

beta

Photos 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

2 of 5

Tuesday workshop

With Josh Tait, Mia Okafor and Sam Carter

A face blurred: no consent on file.

manage

Photos from the team offsite

5 of 5

Consent withdrawn

Summer picnic

With Josh Tait and Ella Clarke

Hidden: consent withdrawn for Ella Clarke.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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) },
  ]}
/>