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).
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>, )
导出下载机制说明
onExport 缺省时,组件会执行内置下载逻辑,行为如下:
- 默认走 blob 下载:先
fetch(image.src)拉取图片并转成blob,再用同源objectURL+<a download>触发下载。这样跨域 CDN 图片也能真正下载,而不会变成「打开新页面」。 - 为什么不用裸
<a download>:download属性只对同源 /blob:/data:URL 生效。直接用跨域地址时浏览器会忽略download,转为普通导航(表现为跳转 / 新开页面)。 - 依赖 CORS:blob 方案要求图片所在 CDN 返回允许跨域读取的响应头(
Access-Control-Allow-Origin)。若 CDN 未配置 CORS,fetch会失败,此时回退到原始地址下载(跨域场景仍可能跳转),保证不静默失败。 - 文件名:从
src的路径末段推断;无有效文件名时交由浏览器决定。 - 完全自定义:传入
onExport(image, index)即接管导出逻辑(如调用业务侧带鉴权的下载接口),组件不再执行默认下载。
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 |
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.