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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then 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.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/lightboxIts 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.10import { 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
Carouselinstead to show the images in the page one at a time, andDialogwhen 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 inonIndexChangeto follow the user. onOpenChange(required)- Type:
(open: boolean) => void - Called with
falsewhen 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
returnFocusgives another element, and the viewer grows from that element. Keep the Lightbox mounted and changeopen: 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 withopen: lay out your own thumbnails instead, since a controlled Lightbox draws the viewer alone.layout: Not withopen: 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 (widthandheight), 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;classNamegives it another width (max-w-none).
Not in this mode (typed never):
index: Not with thumbnails. PassopenandonOpenChangetoo to open the viewer from your own layout.onOpenChange: Not with thumbnails. PassopenandonOpenChangetoo to open the viewer from your own layout.open: Not with thumbnails. PassopenandonOpenChangetoo 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 usesfullScreen:<Lightbox fullScreen>.slides: Not a prop. Pass the images asimages:<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;
}