Skip to main content

Display

Lightbox

Thumbnails that open a full-screen image viewer. Use it for proof screenshots, a listing's photos or before-and-after pairs: pressing a thumbnail opens the viewer on that image. It shows the image as large as the screen allows, uncropped, with its title and caption under it and Close beside the position ("2 of 4") at its top. Step through the images with the arrow keys, the Previous and Next buttons, a trackpad or a swipe, and go to the first or the last with Home or End. The position changes as soon as a key or button chooses an image, and is announced politely, once, when the image arrives. With one image the viewer shows neither the position nor Previous and Next. Escape, Close or a press outside closes it, and focus goes to the thumbnail of the image last shown. The viewer grows from the thumbnail pressed and slides between images; with reduced motion it fades and jumps instead. The thumbnails are fetched as they are drawn, not as they near the screen, so pictures below the fold are there when the reader scrolls to them. Pressing a thumbnail starts fetching its full image before the viewer opens (a finger's or pen's tap, as it lifts without having moved, so a scroll that starts on a thumbnail fetches nothing), and the viewer fetches the image shown first, then the ones a step either side, then the rest. On a phone it runs edge to edge, and an image drawn smaller than its own size zooms to that size with a double-tap or the zoom button, or as far as two fingers pinch it; zoomed, a swipe moves around the image rather than to the next one.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/lightbox#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/lightbox

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/lightbox#v1.3.10 ourostack/ouro-ui/lightbox-tests#v1.3.10
import { Lightbox } from "@/components/ouro/lightbox/lightbox";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Lightbox
  label="Kitchen photos"
  images={[
    { src: "/photos/before.jpg", alt: "The old kitchen, with dark cabinets", title: "Before" },
    { src: "/photos/after.jpg", alt: "The new kitchen, with white cabinets", title: "After", caption: "Six weeks later" },
  ]}
/>
<Lightbox
  label="Kitchen photos"
  images={photos}
  open={open}
  index={index}
  onOpenChange={setOpen}
  onIndexChange={setIndex}
/>

Usage

For a chat message's pictures, pass layout="mosaic": one frame that shows one picture at its own shape, two side by side, three as one large and two small, and four or more as a 2 by 2 grid with "+N" on the last, at most 25rem wide and 17.5rem tall. Pass fullScreen for a viewer that covers the whole screen with no frame. An animated image's still frame shows in its thumbnail when the user asks for reduced motion.

To open the viewer from thumbnails you lay out yourself (a before-and-after pair, a mark on a map), pass open with onOpenChange, the image as index, and follow the user with onIndexChange: it then draws no thumbnails, and focus goes back to whatever had it, or to the element returnFocus gives for the image last shown, such as its thumbnail.

Use instead when

  • Use Carousel instead to show the images in the page one at a time, and Dialog when the viewer holds more than images.

Props

The props the modes share. Type: LightboxProps.

closeLabel
Type: string
Default: "Close"
The close button's accessible name.
fullScreen
Type: boolean
The viewer covers the whole screen at every width, with no frame around it, rather than sitting in a dialog with the page around it. Use it where pictures are the point, as in a chat's photos.
images (required)
Type: readonly LightboxImage[]
The images, in order. The viewer steps through them, and the thumbnails show them in a grid.
label (required)
Type: string
Names the viewer for screen readers, with the position ("Kitchen photos, 2 of 4"), and shows at its top, such as "Kitchen photos". With thumbnails it names their list too. It is always required.
loop
Type: boolean
Next on the last image goes back to the first, and Previous on the first goes to the last.
nextLabel
Type: string
Default: "Next image"
The Next button's accessible name.
onIndexChange
Type: ((index: number) => void)
Called with the image's index when the user moves to another image in the viewer, once per change.
originalLabel
Type: string
Default: "Open original"
The text of the link to an image's href.
positionLabel
Type: string
Default: "{n} of {total}"
The position the viewer shows and announces when it changes: {n} is the image's number and {total} the number of images.
previousLabel
Type: string
Default: "Previous image"
The Previous button's accessible name.
zoomLabel
Type: string
Default: "Zoom"
The zoom button's accessible name. It shows while the image is drawn smaller than its own size, and zooms it to that size, as a double-tap or a pinch does.

Modes

LightboxProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: LightboxControlledProps

A controlled Lightbox: the viewer alone, which you open from thumbnails you lay out yourself.

index
Type: number
Default: 0
The image the viewer shows, by its place in images. It opens on it, and moves to it when you change it while open; update it in onIndexChange to follow the user.
onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user closes the viewer (Close, Escape, a press outside).
open (required)
Type: boolean
Whether the viewer is open. Focus goes back to whatever had it when the viewer opened, unless returnFocus gives another element, and the viewer grows from that element. Keep the Lightbox mounted and change open: one that mounts already open after a press or a key appears and leaves without its transitions, and says so in development.
returnFocus
Type: ((index: number) => HTMLElement | null | undefined)
Where focus goes as the viewer closes, given the index of the image last shown: return that image's thumbnail in your layout, so the user is back where they were looking. Return nothing to give focus back to what had it when the viewer opened.

Not in this mode (typed never):

  • className: Not with open: lay out your own thumbnails instead, since a controlled Lightbox draws the viewer alone.
  • layout: Not with open: a controlled Lightbox draws no thumbnails to lay out.

With thumbnails: LightboxWithThumbnailsProps

A Lightbox that draws its own thumbnails, which open the viewer.

className
Type: string
Layout only, for the thumbnails: width and margins (max-w-2xl, mt-6). Appearance classes warn in development.
layout
Type: "grid" | "mosaic"
Default: "grid"
How the thumbnails sit. grid: as many 4:3 thumbnails in a row as fit. mosaic: one frame, as a chat shows the pictures in a message: one picture at its own shape (width and height), two side by side, three as one large and two small, four or more as a 2 by 2 grid with "+N" on the last; the viewer still steps through them all. The frame is as big as a chat shows pictures: at most 25rem wide and 17.5rem tall, and never wider than its column, so a picture never crowds the words around it; className gives it another width (max-w-none).

Not in this mode (typed never):

  • index: Not with thumbnails. Pass open and onOpenChange too to open the viewer from your own layout.
  • onOpenChange: Not with thumbnails. Pass open and onOpenChange too to open the viewer from your own layout.
  • open: Not with thumbnails. Pass open and onOpenChange too to open the viewer from your own layout.
  • returnFocus: Not with thumbnails: focus goes to the thumbnail of the image last shown.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • fullscreen: Not a prop. Lightbox uses fullScreen: <Lightbox fullScreen>.
  • slides: Not a prop. Pass the images as images: <Lightbox images={[{ src, alt }]}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

LightboxImage

One image in a Lightbox.

interface LightboxImage {
  /** The full-size image's address, shown in the viewer. */
  src: string;
  /** Describes the image for screen readers, on its thumbnail and in the viewer. */
  alt: string;
  /** A smaller file for the thumbnail. @default src */
  thumbnail?: string | undefined;
  /** A short title under the image in the viewer, shown as given. */
  title?: ReactNode;
  /**
   * A line under the title in the viewer, shown as given: Lightbox adds nothing to it.
   */
  caption?: ReactNode;
  /** The original file, or a page about the image: the viewer links to it in a new tab. */
  href?: string | undefined;
  /**
   * The image's own width and height, in any unit. With `layout="mosaic"` and one image, its
   * thumbnail keeps this shape (between 3:4 and 3:1), so the page holds its place before it loads.
   */
  width?: number | undefined;
  /** The image's own height; see `width`. */
  height?: number | undefined;
  /**
   * A still frame of an animated image (a GIF), which its thumbnail shows instead when the user
   * asks for reduced motion. The viewer still plays the image, since opening it asks to see it.
   */
  still?: string | undefined;
}