Skip to contentVibraUI
Media

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-lightbox

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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.
indexnumber—The image showing.
onIndexChange(index: number) => void—Asked for the next index by a key, a button, a swipe or a thumbnail.
openboolean—Whether the viewer is open.
onOpenChange(open: boolean) => void—Escape, Close and the backdrop ask to close it.
loopbooleanfalsePast the last image comes the first again.
showThumbnailsbooleantrue for more than one imageThe strip of thumbnails under the picture.
classNamestring—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

Source

components/ui/image-lightbox/index.tsx
"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&apos;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 }
components/ui/image-lightbox/zoom.ts
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)) }
}