Image lightbox
A full-window viewer over a set of images: arrows, swipe and thumbnails to walk them, zoom and pan, captions and a counter, with the focus kept inside and handed back.
The kit's Dialog, opened over the whole window on the modal layer, with the picture on the page ground. A counter in a status line says which image is showing ("Image 2 of 3" and its alt), so a screen reader hears each one as it comes; its count is a run of its own, left to right, so a right-to-left page keeps "2 of 3" in order. The arrows, Home and End, the Previous and Next buttons, a swipe past 48px and the thumbnail strip walk the images, in reading order: in a right-to-left page ArrowRight and a swipe to the right go back. Without loop it stops at either end and the button there is held: still focusable, so an arrow that walked there keeps the focus, marked aria-disabled, and a press does nothing. With loop the last leads to the first. + and − zoom a whole step at a time up to 4×, 0 fits it again, a double-click zooms 2× about the point clicked (and back), and a zoomed picture is dragged, or nudged with the arrows — which pan rather than walk while it is zoomed, the same way in either direction, since a picture is not a line of text; it can never be moved so far that it stops covering the stage. The zoom eases in and out, and simply jumps under reduced motion. The thumbnail showing is at full strength with a rail of the accent at its foot, named "Show image 3: c" and marked aria-current. The images either side of the one showing are fetched ahead. While a picture loads the stage says so; one that cannot be shown — a missing file, a broken one — says "This image can't be shown" with its alt, as an alert, the way the players announce theirs, and the spinner stops. Escape and Close shut it, and the focus goes back to whatever opened it. It is controlled: hand it the index and open state and listen for their changes.
Install
npx shadcn@latest add @vibra/image-lightboxNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { fileArtFor } from "@/lib/file-art"
import { ImageLightbox, type LightboxImage } from "@/components/ui/image-lightbox"
const IMAGES: LightboxImage[] = [
{ name: "hero-banner-v2.png", detail: "2.4 MB, changed 2 September" },
{ name: "wireframe.png", detail: "860 KB, changed 28 August" },
{ name: "logo-dark.svg", detail: "18 KB, changed 21 August" },
{ name: "app-icon.png", detail: "312 KB, changed 21 August" },
{ name: "chart-export.png", detail: "1.1 MB, changed 14 August" },
{ name: "team-offsite.jpg", detail: "3.8 MB, changed 9 August" },
].map(({ name, detail }) => ({ src: fileArtFor(name), alt: name, caption: `${name}, ${detail}`, width: 1600, height: 1000 }))
export default function ImageLightboxDemo() {
const [open, setOpen] = React.useState(false)
const [index, setIndex] = React.useState(0)
return (
<>
<div className="grid w-full max-w-md grid-cols-3 gap-2">
{IMAGES.map((image, position) => (
<button
key={image.src}
type="button"
aria-label={`Open ${image.alt}`}
onClick={() => {
setIndex(position)
setOpen(true)
}}
className="overflow-hidden rounded-md ring-1 ring-border focus-ring"
>
{/* eslint-disable-next-line @next/next/no-img-element -- the picture the route draws, already the size it is shown at */}
<img src={image.src} alt="" width={1600} height={1000} className="aspect-[16/10] h-auto w-full object-cover" />
</button>
))}
</div>
<ImageLightbox images={IMAGES} index={index} onIndexChange={setIndex} open={open} onOpenChange={setOpen} />
</>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images | { src: string; alt: string; caption?: string; width?: number; height?: number; thumbnail?: string }[] | — | What to show, in order; width and height let it fit a picture before it loads, and thumbnail is a smaller copy for the strip. |
| index | number | — | The image showing. |
| onIndexChange | (index: number) => void | — | Asked for the next index by a key, a button, a swipe or a thumbnail. |
| open | boolean | — | Whether the viewer is open. |
| onOpenChange | (open: boolean) => void | — | Escape, Close and the backdrop ask to close it. |
| loop | boolean | false | Past the last image comes the first again. |
| showThumbnails | boolean | true for more than one image | The strip of thumbnails under the picture. |
| className | string | — | Joined to the dialog's own classes. |
| zoomAt() | (point, from: { zoom, pan }, zoom: number) => { zoom, pan } | — | The zoom about a point that keeps the picture under it still — the math the double-click uses. |
| clampPan() | (pan, zoom, content, viewport) => pan | — | A pan held to what the zoomed picture can show: covering the viewport on a side larger than it, centred on a side smaller. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon, ImageOffIcon, XIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { clampPan, zoomAt, type ZoomView } from "./zoom"
export { clampPan, zoomAt, type ZoomView } from "./zoom"
export type LightboxImage = {
src: string
alt: string
caption?: string
/** The picture's own size, when known: the viewer can fit it before it loads. */
width?: number
height?: number
/** A smaller copy for the thumbnail strip; the picture itself otherwise. */
thumbnail?: string
}
export type ImageLightboxProps = {
images: LightboxImage[]
index: number
onIndexChange: (index: number) => void
open: boolean
onOpenChange: (open: boolean) => void
/** Past the last image comes the first again. */
loop?: boolean
showThumbnails?: boolean
className?: string
}
type Point = { x: number; y: number }
type Size = { width: number; height: number }
const ORIGIN: Point = { x: 0, y: 0 }
const MAX_ZOOM = 4
/** How far a finger travels before it is a swipe. */
const SWIPE = 48
/** How far an arrow moves a zoomed picture. */
const NUDGE = 80
/** Whether an element sits in a right-to-left run: the nearest dir attribute, else its computed direction. */
function isRightToLeft(element: Element): boolean {
const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (attribute === "rtl" || attribute === "ltr") return attribute === "rtl"
return getComputedStyle(element).direction === "rtl"
}
/**
* A full-window viewer over a set of images: the arrows, Home, End, a swipe
* and the buttons walk them in reading order; +, − and 0, the zoom buttons
* and a double-click zoom, and a zoomed picture is dragged or nudged with the
* arrows. A counter says which image is showing; the focus stays inside and
* goes back to what opened it.
*/
function ImageLightbox({ images, index, onIndexChange, open, onOpenChange, loop = false, showThumbnails = images.length > 1, className }: ImageLightboxProps) {
const stageRef = React.useRef<HTMLDivElement>(null)
const pictureRef = React.useRef<HTMLImageElement>(null)
const count = images.length
const at = Math.min(Math.max(0, index), Math.max(0, count - 1))
const image = images[at]
const [stage, setStage] = React.useState<Size | null>(null)
// Each keyed by the image it belongs to, so walking on starts clean.
const [view, setView] = React.useState<ZoomView & { src?: string }>({ zoom: 1, pan: ORIGIN })
const [natural, setNatural] = React.useState<(Size & { src: string }) | null>(null)
const [failed, setFailed] = React.useState<string | null>(null)
const [dragging, setDragging] = React.useState(false)
const gesture = React.useRef<{ from: Point; pan: Point; id: number } | null>(null)
const current: ZoomView = view.src === image?.src ? view : { zoom: 1, pan: ORIGIN }
const loaded = natural?.src === image?.src ? natural : image?.width && image.height ? { width: image.width, height: image.height } : null
const broken = failed === image?.src
const fitted = stage && loaded ? (() => {
const scale = Math.min(1, stage.width / loaded.width, stage.height / loaded.height)
return { width: loaded.width * scale, height: loaded.height * scale }
})() : null
const show = (next: ZoomView) => {
const zoom = Math.min(MAX_ZOOM, Math.max(1, next.zoom))
const pan = fitted && stage && zoom > 1 ? clampPan(next.pan, zoom, fitted, stage) : ORIGIN
setView({ zoom, pan, src: image?.src })
}
const go = (to: number) => {
const next = loop ? (to + count) % count : Math.min(count - 1, Math.max(0, to))
if (next !== at) onIndexChange(next)
}
const measure = React.useCallback(() => {
const node = stageRef.current
if (!node) return
const { width, height } = node.getBoundingClientRect()
setStage((previous) => (previous?.width === width && previous.height === height ? previous : { width, height }))
}, [])
// The stage exists only while the dialog is open: measured as it mounts,
// and again whenever it resizes.
const stageNode = React.useCallback(
(node: HTMLDivElement | null) => {
stageRef.current = node
if (!node) return
measure()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(() => measure())
observer.observe(node)
return () => observer.disconnect()
},
[measure]
)
// The images either side are fetched ahead, so walking on shows one at once.
React.useEffect(() => {
if (!open || count < 2) return
for (const offset of [-1, 1]) {
const neighbour = images[loop ? (at + offset + count) % count : at + offset]
if (!neighbour || neighbour === image) continue
const preload = new Image()
preload.src = neighbour.src
}
}, [open, at, count, loop, images, image])
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.altKey || event.ctrlKey || event.metaKey) return
const zoomed = current.zoom > 1
const rtl = isRightToLeft(event.currentTarget)
const key = event.key
if (key === "+" || key === "=") show({ ...current, zoom: current.zoom + 1 })
else if (key === "-" || key === "_") show({ ...current, zoom: current.zoom - 1 })
else if (key === "0") show({ zoom: 1, pan: ORIGIN })
else if (zoomed && key.startsWith("Arrow")) {
// A zoomed picture is a picture: the arrows move the view over it,
// the same way in either reading direction.
const step: Record<string, Point> = { ArrowRight: { x: -NUDGE, y: 0 }, ArrowLeft: { x: NUDGE, y: 0 }, ArrowDown: { x: 0, y: -NUDGE }, ArrowUp: { x: 0, y: NUDGE } }
const by = step[key]
if (!by) return
show({ ...current, pan: { x: current.pan.x + by.x, y: current.pan.y + by.y } })
} else if (key === "ArrowRight" || key === "ArrowLeft") go(at + ((key === "ArrowRight") !== rtl ? 1 : -1))
else if (key === "Home") go(0)
else if (key === "End") go(count - 1)
else return
event.preventDefault()
}
/** A point on screen, from the stage's centre — where the picture's centre sits unpanned. */
const fromCentre = (clientX: number, clientY: number): Point => {
const box = stageRef.current?.getBoundingClientRect()
return box ? { x: clientX - box.left - box.width / 2, y: clientY - box.top - box.height / 2 } : ORIGIN
}
/**
* A press on one of the stage's own buttons — Previous, Next — is the
* button's. Captured, its pointerup and its click would go to the stage
* instead, and the button would never hear them; and a double press on it
* is not a double-click on the picture.
*/
const onButton = (event: React.SyntheticEvent) => (event.target as Element).closest("button") !== null
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if ((event.pointerType === "mouse" && event.button !== 0) || onButton(event)) return
event.currentTarget.setPointerCapture?.(event.pointerId)
gesture.current = { from: { x: event.clientX, y: event.clientY }, pan: current.pan, id: event.pointerId }
}
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const start = gesture.current
if (!start || start.id !== event.pointerId || current.zoom <= 1) return
setDragging(true)
show({ ...current, pan: { x: start.pan.x + event.clientX - start.from.x, y: start.pan.y + event.clientY - start.from.y } })
}
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const start = gesture.current
gesture.current = null
setDragging(false)
event.currentTarget.releasePointerCapture?.(event.pointerId)
if (!start || start.id !== event.pointerId || current.zoom > 1) return
const dx = event.clientX - start.from.x
const dy = event.clientY - start.from.y
if (Math.abs(dx) < SWIPE || Math.abs(dx) < Math.abs(dy)) return
// Dragged towards the start of the line, the next image comes in from its end.
const forward = dx < 0 !== isRightToLeft(event.currentTarget)
go(at + (forward ? 1 : -1))
}
if (!image) return null
return (
<Dialog open={open} onOpenChange={(next) => onOpenChange(next)}>
<DialogContent
showCloseButton={false}
initialFocus={stageRef}
data-slot="image-lightbox"
data-zoom={current.zoom}
onKeyDown={onKeyDown}
className={cn(
"inset-2 flex w-auto max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:inset-4 sm:max-w-none",
className
)}
>
<DialogTitle className="sr-only">Image viewer</DialogTitle>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-rule ps-4 pe-2">
<p role="status" className="min-w-0 flex-1 truncate text-sm font-medium tabular-nums">
<span className="sr-only">Image </span>
{/* A run of its own, left to right: a right-to-left page would turn "2 of 16" round. */}
<bdi dir="ltr">
{at + 1} of {count}
</bdi>
<span className="sr-only">: {image.alt}</span>
</p>
<Button variant="ghost" size="icon-sm" aria-label="Zoom out" disabled={current.zoom <= 1} onClick={() => show({ ...current, zoom: current.zoom - 1 })}>
<ZoomOutIcon aria-hidden="true" />
</Button>
<span className="w-11 text-center text-xs text-muted-foreground tabular-nums">{current.zoom * 100}%</span>
<Button variant="ghost" size="icon-sm" aria-label="Zoom in" disabled={current.zoom >= MAX_ZOOM} onClick={() => show({ ...current, zoom: current.zoom + 1 })}>
<ZoomInIcon aria-hidden="true" />
</Button>
<DialogClose render={<Button variant="ghost" size="icon-sm" aria-label="Close" className="ms-1" />}>
<XIcon aria-hidden="true" />
</DialogClose>
</div>
<div
ref={stageNode}
tabIndex={-1}
data-slot="image-lightbox-stage"
data-dragging={dragging || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => {
gesture.current = null
setDragging(false)
}}
onDoubleClick={(event) => {
if (onButton(event)) return
show(current.zoom > 1 ? { zoom: 1, pan: ORIGIN } : zoomAt(fromCentre(event.clientX, event.clientY), current, 2))
}}
className={cn("relative flex min-h-0 flex-1 touch-none items-center justify-center overflow-hidden bg-background select-none focus-ring-inset", current.zoom > 1 && "cursor-grab data-dragging:cursor-grabbing")}
>
{/* Plain <img> rather than next/image: the viewer scales the picture itself, and an optimised copy would be neither its size nor its pixels. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={image.src}
ref={pictureRef}
src={image.src}
alt={image.alt}
draggable={false}
onLoad={(event) => setNatural({ src: image.src, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
onError={() => setFailed(image.src)}
className={cn(
"max-h-full max-w-full object-contain transition-transform duration-(--duration-base) ease-(--ease-standard) data-dragging:transition-none",
broken && "invisible"
)}
data-dragging={dragging || undefined}
style={{ transform: `translate(${current.pan.x}px, ${current.pan.y}px) scale(${current.zoom})` }}
/>
{broken ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
<ImageOffIcon aria-hidden="true" className="mb-1 size-6 text-muted-foreground" />
{/* Said as it happens, wherever the reader is, as the players say theirs: the
counter above only says which image this is. */}
<div role="alert" className="flex flex-col items-center gap-1">
<p className="text-sm font-medium">This image can't be shown</p>
<p className="text-xs text-muted-foreground">{image.alt}</p>
</div>
</div>
) : natural?.src !== image.src ? (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<Spinner aria-label="Loading image" className="size-6 text-muted-foreground" />
</div>
) : null}
{/* Held at either end without loop, but still focusable: the arrow
that walked there keeps the focus instead of dropping it on the
page, says it is held (aria-disabled), and a press does nothing. */}
{count > 1 ? (
<>
<Button
variant="outline"
size="icon"
aria-label="Previous image"
disabled={!loop && at === 0}
focusableWhenDisabled
onClick={() => go(at - 1)}
className="absolute start-3 top-1/2 -translate-y-1/2 rounded-full"
>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Next image"
disabled={!loop && at === count - 1}
focusableWhenDisabled
onClick={() => go(at + 1)}
className="absolute end-3 top-1/2 -translate-y-1/2 rounded-full"
>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</>
) : null}
</div>
{image.caption || showThumbnails ? (
<div className="flex shrink-0 flex-col gap-2 border-t border-rule px-4 py-3">
{image.caption ? <p className="truncate text-sm text-muted-foreground">{image.caption}</p> : null}
{showThumbnails ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((thumb, position) => (
<button
key={thumb.src}
type="button"
aria-label={`Show image ${position + 1}: ${thumb.alt}`}
aria-current={position === at ? "true" : undefined}
onClick={() => go(position)}
// The one showing is at full strength with a rail of the accent at
// its foot: a stroke round a box is what focus looks like.
className="relative h-12 w-16 shrink-0 overflow-hidden rounded-md opacity-60 ring-1 ring-border transition-opacity duration-(--duration-fast) after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-brand after:opacity-0 hover:opacity-100 focus-ring aria-[current=true]:opacity-100 aria-[current=true]:after:opacity-100"
>
{/* eslint-disable-next-line @next/next/no-img-element -- a thumbnail of the picture above */}
<img src={thumb.thumbnail ?? thumb.src} alt="" draggable={false} className="size-full object-cover" />
</button>
))}
</div>
) : null}
</div>
) : null}
</DialogContent>
</Dialog>
)
}
export { ImageLightbox }type Point = { x: number; y: number }
type Size = { width: number; height: number }
/** How far in a picture is zoomed, and how far it has been moved from the centre, in screen pixels. */
export type ZoomView = { zoom: number; pan: Point }
/** A negative zero (or NaN) read as the zero it means. */
const tidy = (value: number) => value || 0
/**
* The view zoomed to `zoom` about a point given from the viewport's centre:
* the part of the picture under that point stays under it, as a double-click
* or a pinch expects. Not clamped — pass the pan to `clampPan`.
*/
export function zoomAt(point: Point, from: ZoomView, zoom: number): ZoomView {
const ratio = zoom / from.zoom
return {
zoom,
pan: { x: tidy(point.x - (point.x - from.pan.x) * ratio), y: tidy(point.y - (point.y - from.pan.y) * ratio) },
}
}
/**
* The pan held to what the zoomed picture can show: a side larger than the
* viewport can travel half its overhang each way and no further, so the
* picture always covers the viewport on that side; a side smaller than the
* viewport stays centred. `content` is the picture's size at zoom 1.
*/
export function clampPan(pan: Point, zoom: number, content: Size, viewport: Size): Point {
const reach = (length: number, room: number) => Math.max(0, (length * zoom - room) / 2)
const clamp = (value: number, limit: number) => tidy(Math.min(limit, Math.max(-limit, value)))
return { x: clamp(pan.x, reach(content.width, viewport.width)), y: clamp(pan.y, reach(content.height, viewport.height)) }
}