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).

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>,
)

导出下载机制说明

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.

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
childrenReactNodeTrigger element (uncontrolled mode)
openbooleanControlled open state
defaultOpenbooleanfalseUncontrolled initial open state
onOpenChange(open: boolean) => voidOpen-state change callback
indexnumberControlled active index
defaultIndexnumber0Uncontrolled initial index
onIndexChange(index: number) => voidActive-index change callback
onDelete(index: number) => voidDelete callback; the delete button shows only when provided
onExport(image: { src; alt? }, index: number) => voidExport callback; falls back to a blob fetch + download when omitted
minZoomnumber0.5Minimum zoom scale
maxZoomnumber3Maximum zoom scale
zoomStepnumber0.25Zoom step per click
classNamestringClass 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.