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.
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.
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.
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:
- Default (neither prop) — the component runs its built-in download.
resolveExportSrc(address only) — the built-in download still runs, but against an address you resolve first.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 ablob, then a same-originobjectURL+<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>: thedownloadattribute only applies to same-origin /blob:/data:URLs. Given a cross-origin address the browser ignoresdownloadand 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 thefetchfails 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 toimage.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'sfetchwait 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 norresolveExportSrc.
// 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.
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
| Part | Token |
|---|---|
| Backdrop | Overlays/Default#00000066 |
| Toolbar background | Foregrounds/Tooltip#3d3d3d |
| Toolbar icon | Foregrounds/White#ffffff |
| Disabled icon | Labels/Disabled#808080 |
| Geometry | Value | Token |
|---|---|---|
| Toolbar radius | 5px | Radius_5 |
| Toolbar padding / gap / button padding | 4px | Spacing_4 |
| Toolbar offset from bottom | 24px | Spacing_24 |
| Icon container | 20×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
| Prop | Type | Default | Description |
|---|---|---|---|
images | (string | { src: string; alt?: string })[] | — | Image list; pass a one-element array for a single image |
children | ReactNode | — | Trigger element (uncontrolled mode) |
open | boolean | — | Controlled open state |
defaultOpen | boolean | false | Uncontrolled initial open state |
onOpenChange | (open: boolean) => void | — | Open-state change callback |
index | number | — | Controlled active index |
defaultIndex | number | 0 | Uncontrolled 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 |
minZoom | number | 0.5 | Minimum zoom scale |
maxZoom | number | 3 | Maximum zoom scale |
zoomStep | number | 0.25 | Zoom step per click |
className | string | — | Class applied to the content layer |
Usage Constraints
- The viewer is a full-screen overlay built on the Radix Dialog primitive;
childrenacts as the trigger in uncontrolled mode. - The imperative
ImageViewer.open()requiresDesignProviderorOverlayHostat the application root. - The component does not mutate
imageson delete — update the list from theonDeletecallback in the caller. - Zoom resets to
1whenever the active image changes or the viewer reopens.