58 / Blocks
Lightbox
One image at a time, full bleed, with the rest of the set a key away.
"use client"
import * as React from "react"
import { galleryImages } from "@/components/demos/image-gallery-demo"
import { ImageGrid } from "mischief-ui/image-grid"
import { Lightbox } from "mischief-ui/lightbox"
const images = galleryImages.slice(0, 4)
export function LightboxDemo() {
const [openId, setOpenId] = React.useState<string | null>(null)
const trigger = React.useRef<HTMLButtonElement>(null)
return (
<div className="w-full max-w-lg">
<ImageGrid
images={images}
onSelect={(image, event) => {
trigger.current = event.currentTarget
setOpenId(image.id)
}}
/>
<Lightbox
finalFocus={trigger}
images={images}
openId={openId}
onOpenIdChange={setOpenId}
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/lightboximport { Lightbox } from "mischief-ui/lightbox"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" /* eslint-disable @next/next/no-img-element -- This must work outside Next.js. */ import * as React from "react"import { Dialog } from "@base-ui/react/dialog"import { ChevronLeft, ChevronRight, Download, X } from "lucide-react" import { cn } from "@/lib/utils" /** Structurally the same shape Image Grid uses, so the two interchange. */export interface GalleryImage { id: string src: stringUsage
export function Viewer() {
const [openId, setOpenId] = useState<string | null>(null)
return (
<Lightbox images={images} openId={openId} onOpenIdChange={setOpenId} />
)
}It holds nothing
Which image is showing is yours: openId in, onOpenIdChange out, including the null that closes it. That is what lets the same lightbox be opened from a grid, from a table row, or from a link somewhere else on the page, and what lets the open image live in the URL if you want it to.
Moving through the set is the component's job. Left and Right Arrow wrap around the ends, and the controls are hidden entirely when there is only one image, rather than shown doing nothing.
What Base UI is doing here
The focus trap, the scroll lock, Escape, the backdrop, and returning focus on close all come from Base UI's dialog rather than from anything written here, which is why this is the only part of a gallery that needs it. Image Grid needs nothing but React.
Pass finalFocus so focus returns to the tile that opened the image rather than to the top of the page.
API
imagesGalleryImage[]The whole set, so it can move through them.openIdstring | nullThe image being shown. Null closes it.onOpenIdChange(id: string | null) => voidCalled to move, and with null to close.renderImage(image: GalleryImage) => ReactNodeReplaces the img.finalFocusRefObject<HTMLElement>Where focus lands on close, usually the tile that opened it.closeLabelstringNames the close control. Defaults to "Close".Accessibility
It takes the same image shape Image Grid takes, written out here rather than imported, so neither has to be installed for the other. A real dialog: focus is trapped while it is open, the page behind it does not scroll, Escape closes it, and focus returns to finalFocus afterwards. The image's alt is the dialog's accessible name, and its position in the set is the description, so a screen reader hears which of how many it is. Arrow keys move; the previous and next controls are absent rather than disabled when there is nowhere to go.