Skip to main content

ImageViewer

  • Component overview: Full-screen image viewer (lightbox) that centers an image over a dim backdrop, with a floating bottom toolbar for navigation, zoom, delete, export, and close.
  • Interaction: Built-in multi-image navigation (prev / next, disabled at bounds, ← / → keys), zoom in / out within bounds (+ / = / - keys), optional delete, export (falls back to browser download), and close via the shrink button, Esc, or backdrop click.
  • Implementation note: No ui/ primitive — built directly in the design layer on the Radix Dialog primitive, reusing its portal / focus trap / Esc / scroll-lock / a11y while the design layer fully owns the visuals.
  • Figma spec

Basic Usage​

Pass an images array and a trigger element as children. Prev / next navigate the list; both are disabled at the bounds.

Result
Loading...
Live Editor
const images = [
  { src: 'https://picsum.photos/seed/plaud-a/900/600', alt: 'Sample A' },
  { src: 'https://picsum.photos/seed/plaud-b/900/600', alt: 'Sample B' },
  { src: 'https://picsum.photos/seed/plaud-c/900/600', alt: 'Sample C' },
]
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer images={images}>
      <Button>Open viewer</Button>
    </ImageViewer>
  </div>,
)

Single Image​

For a single image, pass a one-element array. The prev / next buttons (and their divider) are hidden; zoom, export, and close remain available.

Result
Loading...
Live Editor
const images = ['https://picsum.photos/seed/plaud-single/1200/800']
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer images={images}>
      <Button variant="secondary">Open single image</Button>
    </ImageViewer>
  </div>,
)

Delete & Export​

Pass onDelete to render the delete button. onExport overrides the default download (which fetches the image as a blob before saving, so cross-origin CDN URLs download instead of navigating away). To keep the built-in download but change only which address it saves, use resolveExportSrc instead of taking over.

Result
Loading...
Live Editor
const images = [
  { src: 'https://picsum.photos/seed/plaud-d1/900/600', alt: 'Photo 1' },
  { src: 'https://picsum.photos/seed/plaud-d2/900/600', alt: 'Photo 2' },
]
render(
  <div style={{ padding: '40px 0' }}>
    <ImageViewer
      images={images}
      onDelete={(index) => alert('delete index: ' + index)}
      onExport={(image, index) => alert('export ' + index + ': ' + image.src)}
    >
      <Button>Open with delete & export</Button>
    </ImageViewer>
  </div>,
)

Export download mechanics​

There are three export modes, in ascending order of how much the caller takes over:

  1. Default (neither prop) — the component runs its built-in download.
  2. resolveExportSrc (address only) — the built-in download still runs, but against an address you resolve first.
  3. onExport (full takeover) — the component performs no download at all.

When onExport is omitted, the built-in download behaves as follows:

  • Blob download by default: fetch(image.src) retrieves the image as a blob, then a same-origin objectURL + <a download> triggers the save. This is what makes cross-origin CDN images actually download instead of opening in a new page.
  • Why not a bare <a download>: the download attribute only applies to same-origin / blob: / data: URLs. Given a cross-origin address the browser ignores download and navigates instead (the user sees a redirect / new tab).
  • CORS dependency: the blob approach requires the image's CDN to return Access-Control-Allow-Origin. Without CORS the fetch fails and the component falls back to downloading the raw address (which may still navigate for cross-origin URLs) — degraded, but never silent.
  • Filename: inferred from the last path segment of src; when no usable name is found, the browser decides.
  • Address only (resolveExportSrc(image, index)): called right before the download to resolve the address actually used — for time-limited addresses such as re-signing an expired presigned URL. Return a new address to download it; return an empty value or throw and the component falls back to image.src. Everything above (blob, cache: 'reload', cross-origin fallback, filename inference) still applies. The export button shows no loading state while it resolves, the same as today's fetch wait window.
  • Full takeover (onExport(image, index)): takes over the export action entirely (e.g. calling an authenticated business download endpoint). The component runs neither the default download nor resolveExportSrc.
// Keep the built-in download, only swap an expired address for a fresh one
<ImageViewer
images={[src]}
resolveExportSrc={(image) => (isExpired(image.src) ? resign(key) : undefined)}
/>

Controlled Mode​

Control open and index externally to drive the viewer from your own state.

Result
Loading...
Live Editor
const images = [
  { src: 'https://picsum.photos/seed/plaud-c1/900/600', alt: 'Image 1' },
  { src: 'https://picsum.photos/seed/plaud-c2/900/600', alt: 'Image 2' },
  { src: 'https://picsum.photos/seed/plaud-c3/900/600', alt: 'Image 3' },
]
const Demo = () => {
  const [open, setOpen] = useState(false)
  const [index, setIndex] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '40px 0' }}>
      <Button onClick={() => setOpen(true)}>Open controlled</Button>
      <span style={{ fontSize: 14, color: '#999' }}>index: {index}</span>
      <ImageViewer
        images={images}
        open={open}
        onOpenChange={setOpen}
        index={index}
        onIndexChange={setIndex}
      />
    </div>
  )
}
render(<Demo />)

Imperative API​

ImageViewer.open() opens the viewer imperatively — useful for gallery thumbnails, table row actions, or keyboard shortcuts where a JSX trigger is not available. Requires DesignProvider or OverlayHost mounted at the application root. It returns a controller with close / update / afterClosed.

import { ImageViewer } from '@plaud/design'

const controller = ImageViewer.open({
images: ['/a.png', '/b.png', '/c.png'],
defaultIndex: 0,
onDelete: (index) => {
// remove the image from your own list, then sync the viewer
controller.update({ images: nextImages })
},
onExport: (image) => download(image.src),
})

// close from anywhere
controller.close()
await controller.afterClosed

ImageViewer.open() accepts the same options as the declarative component except children / open / defaultOpen (visibility is managed by the overlay host).

Tokens​

PartToken
BackdropOverlays/Default#00000066
Toolbar backgroundForegrounds/Tooltip#3d3d3d
Toolbar iconForegrounds/White#ffffff
Disabled iconLabels/Disabled#808080
GeometryValueToken
Toolbar radius5pxRadius_5
Toolbar padding / gap / button padding4pxSpacing_4
Toolbar offset from bottom24pxSpacing_24
Icon container20×20px—
Toolbar button hover (fallback)white/10—
Divider (fallback)white/20, height 28px—
Image max size (fallback)max-h-[80vh] / max-w-[min(900px,90vw)]—

Props​

PropTypeDefaultDescription
images(string | { src: string; alt?: string })[]—Image list; pass a one-element array for a single image
childrenReactNode—Trigger element (uncontrolled mode)
openboolean—Controlled open state
defaultOpenbooleanfalseUncontrolled initial open state
onOpenChange(open: boolean) => void—Open-state change callback
indexnumber—Controlled active index
defaultIndexnumber0Uncontrolled initial index
onIndexChange(index: number) => void—Active-index change callback
onDelete(index: number) => void—Delete callback; the delete button shows only when provided
onExport(image: { src; alt? }, index: number) => void—Export callback; falls back to a blob fetch + download when omitted
resolveExportSrc(image: { src; alt? }, index: number) => string | null | undefined | Promise<...>—Resolves the address used by the default download (e.g. re-signing an expired presigned URL); an empty value or a thrown error falls back to image.src. Not called when onExport is passed
minZoomnumber0.5Minimum zoom scale
maxZoomnumber3Maximum zoom scale
zoomStepnumber0.25Zoom step per click
classNamestring—Class applied to the content layer

Usage Constraints​

  • The viewer is a full-screen overlay built on the Radix Dialog primitive; children acts as the trigger in uncontrolled mode.
  • The imperative ImageViewer.open() requires DesignProvider or OverlayHost at the application root.
  • The component does not mutate images on delete — update the list from the onDelete callback in the caller.
  • Zoom resets to 1 whenever the active image changes or the viewer reopens.