# Image Viewer

A dialog image viewer with navigation, zoom, rotation, and panning.

Page: https://sui.draco.dev/docs/components/image-viewer

### Example: image-viewer-demo

```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";
import type { ExampleProps } from "../types";

const colors = ["#70866A", "#648493", "#BC6C73"];
const images = colors.map(
  (color, index) =>
    "data:image/svg+xml," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640"><rect width="960" height="640" fill="${color}"/><circle cx="${240 + index * 100}" cy="240" r="180" fill="#ffffff" opacity=".4"/><path d="M0 560L960 180" stroke="#ffffff" stroke-width="40" opacity=".6"/><text x="60" y="600" font-family="sans-serif" font-size="56" fill="white">SUI ${index + 1}</text></svg>`,
    ),
);
export default function Example({ locale }: ExampleProps) {
  const chinese = locale === "zh-CN";
  const [open, setOpen] = useState(false);
  const [initialIndex, setInitialIndex] = useState(0);
  return (
    <div className="grid w-full gap-4">
      <div className="grid grid-cols-3 gap-3">
        {images.map((image, index) => (
          <Button
            key={image}
            type="button"
            variant="ghost"
            className="h-auto w-full overflow-hidden rounded-xl p-0"
            aria-label={
              chinese
                ? `查看示例图案 ${index + 1}`
                : `View example pattern ${index + 1}`
            }
            onClick={() => {
              setInitialIndex(index);
              setOpen(true);
            }}
          >
            <img
              src={image}
              alt={
                chinese
                  ? `示例图案 ${index + 1}`
                  : `Example pattern ${index + 1}`
              }
              className="aspect-[3/2] w-full object-cover"
            />
          </Button>
        ))}
      </div>
      <ImageViewer
        images={images}
        open={open}
        initialIndex={initialIndex}
        onClose={() => setOpen(false)}
        alt={chinese ? "示例图案" : "Example pattern"}
        labels={
          chinese
            ? {
                defaultAlt: "图片",
                loading: "正在加载图片",
                error: "无法加载图片",
                retry: "重试",
                viewer: "图片查看器",
                zoomOut: "缩小",
                zoomIn: "放大",
                rotateCounterclockwise: "逆时针旋转",
                rotateClockwise: "顺时针旋转",
                reset: "重置图片",
                close: "关闭图片查看器",
                previous: "上一张",
                next: "下一张",
                imageAlt: (alt, index) => `${alt} ${index}`,
                open: (alt, index) => `打开${alt} ${index}`,
                thumbnail: (alt, index) => `${alt}缩略图 ${index}`,
                position: (index, total) => `第 ${index} 张，共 ${total} 张`,
              }
            : undefined
        }
      />
    </div>
  );
}
```

## Installation

```bash
bunx --bun shadcn@latest add @sui/image-viewer
```

Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.

## Usage

```tsx
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";

const images = ["/photos/first.jpg", "/photos/second.jpg"];

export function Gallery() {
  const [open, setOpen] = useState(false);
  const [initialIndex, setInitialIndex] = useState(0);
  return (
    <>
      {images.map((src, index) => (
        <Button
          key={src}
          type="button"
          variant="ghost"
          className="h-auto overflow-hidden rounded-xl p-0"
          onClick={() => {
            setInitialIndex(index);
            setOpen(true);
          }}
        >
          <img src={src} alt={`Photo ${index + 1}`} />
        </Button>
      ))}
      <ImageViewer
        images={images}
        initialIndex={initialIndex}
        open={open}
        onClose={() => setOpen(false)}
        alt="Photo gallery"
      />
    </>
  );
}
```

## Opening and navigation

Opening is controlled through `open` and `onClose`. Supply one image URL or an array. With multiple images, navigation controls and thumbnails let the reader switch images. Zoom, rotation, reset, and panning actions are available inside the viewer. Empty image arrays render nothing. Wheel and pinch gestures zoom, and dragging pans. Arrow keys navigate, `+`/`-` zoom, and `0` resets the transform. Loading failures provide a retry action. Replace the image paths in the snippet with your own assets.

## Controlled image index

`initialIndex` sets the initial uncontrolled image, using zero-based indexes. For external selection, pass `index` and update it in `onIndexChange`. Selecting another image resets its transform. Reset is enabled only after zooming, rotating, or panning changes the image. The example opens a specific image and displays the selected zero-based index. Opening an uncontrolled viewer restores `initialIndex`; changing `initialIndex` while open also updates the selection.

### Example: image-viewer-controlled

```tsx
"use client";
import { Button } from "@workspace/ui/components/button";
import { ImageViewer } from "@workspace/ui/components/image-viewer";
import { useState } from "react";
import type { ExampleProps } from "../types";

const colors = ["#70866A", "#648493", "#BC6C73"];
const images = colors.map(
  (color, index) =>
    "data:image/svg+xml," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640" viewBox="0 0 960 640"><rect width="960" height="640" fill="${color}"/><circle cx="${240 + index * 100}" cy="240" r="180" fill="#ffffff" opacity=".4"/><path d="M0 560L960 180" stroke="#ffffff" stroke-width="40" opacity=".6"/><text x="60" y="600" font-family="sans-serif" font-size="56" fill="white">SUI ${index + 1}</text></svg>`,
    ),
);
export default function Example({ locale }: ExampleProps) {
  const chinese = locale === "zh-CN";
  const [open, setOpen] = useState(false);
  const [index, setIndex] = useState(0);
  return (
    <div className="grid w-full gap-4">
      <div className="grid grid-cols-3 gap-3">
        {images.map((image, imageIndex) => (
          <Button
            key={image}
            variant="ghost"
            type="button"
            className="h-auto w-full overflow-hidden rounded-xl p-0"
            aria-label={
              chinese
                ? `查看几何图案 ${imageIndex + 1}`
                : `View geometric pattern ${imageIndex + 1}`
            }
            onClick={() => {
              setIndex(imageIndex);
              setOpen(true);
            }}
          >
            <img
              src={image}
              alt={
                chinese
                  ? `几何图案 ${imageIndex + 1}`
                  : `Geometric pattern ${imageIndex + 1}`
              }
              className="aspect-[3/2] w-full object-cover"
            />
          </Button>
        ))}
      </div>
      <output className="text-muted-foreground text-sm" aria-live="polite">
        {chinese
          ? `选中索引：${index}（从 0 开始）`
          : `Selected index: ${index} (zero-based)`}
      </output>
      <ImageViewer
        images={images}
        open={open}
        onClose={() => setOpen(false)}
        index={index}
        onIndexChange={setIndex}
        alt={chinese ? "几何图案" : "Geometric pattern"}
        labels={
          chinese
            ? {
                defaultAlt: "图片",
                loading: "正在加载图片",
                error: "无法加载图片",
                retry: "重试",
                viewer: "图片查看器",
                zoomOut: "缩小",
                zoomIn: "放大",
                rotateCounterclockwise: "逆时针旋转",
                rotateClockwise: "顺时针旋转",
                reset: "重置图片",
                close: "关闭图片查看器",
                previous: "上一张",
                next: "下一张",
                imageAlt: (alt, index) => `${alt} ${index}`,
                open: (alt, index) => `打开${alt} ${index}`,
                thumbnail: (alt, index) => `${alt}缩略图 ${index}`,
                position: (index, total) => `第 ${index} 张，共 ${total} 张`,
              }
            : undefined
        }
      />
    </div>
  );
}
```

## Labels, focus, and portal container

The viewer uses the shared [Dialog](/docs/components/dialog) for modal focus management and dismissal. `alt` describes the images, and `labels` customizes dialog, navigation, zoom, rotation, thumbnail, and position announcements. Function labels receive one-based image positions, while `index` and `onIndexChange` use zero-based indexes.

Use `container` to choose a Portal container when embedding the viewer in a local themed region. The glass effect applies to its surrounding dialog surface; the image itself remains clear.

## API reference

| Prop | Type | Default / behavior |
| --- | --- | --- |
| `images` | `string \| string[]` | Required image URL(s). |
| `open` | `boolean` | Required controlled dialog state. |
| `onClose` | `() => void` | Required close callback. |
| `index` | `number` | Controlled zero-based image index. |
| `initialIndex` | `number` | `0`; initial uncontrolled image. |
| `onIndexChange` | `(index: number) => void` | Reports selected zero-based index. |
| `alt` | `string` | `labels.defaultAlt` or `"Image"`. |
| `container` | `HTMLElement \| ShadowRoot \| RefObject<HTMLElement \| ShadowRoot \| null> \| null` | Optional Portal container. |
| `labels` | `Partial<ImageViewerLabels>` | English action and image labels. |
| `glass` | `boolean` | `false`. |

The module exports `ImageViewerProps` and `ImageViewerLabels`. The underlying modal behavior is documented in the [Base UI Dialog API](https://base-ui.com/react/components/dialog).
