The Image Viewer renders images the way the browser is fastest at: every frame is
decoded off the main thread with
createImageBitmap
and drawn straight to a DPR-aware canvas, so scrolling never blocks on decode. It
does continuous-scroll rendering, zoom, rotate, fit-to-width, and download, and
exposes a per-frame overlay slot so you can draw bounding-box citations (edit
fields, extraction sources) on top of the rendered image.
It treats a multi-page TIFF as N frames — rendered exactly like N PDF pages.
Browsers can't decode TIFF natively, so the viewer decodes it with
UTIF.js in a Web Worker: the file bytes
are transferred in once, and each near-viewport frame is decoded off-thread and
handed back as a transferred ImageBitmap — so the decode and the pixels never
touch the main heap. It caps how many decoded bitmaps it keeps and frees the
worker's per-frame buffers as it goes, so a hundred-page scan stays smooth and
flat in memory.
It is built without useEffect: the image source loads with React's use() and
Suspense, frames mount lazily via an IntersectionObserver, and each canvas
draws from a ref callback (releasing its bitmap on cleanup).
Installation
pnpm dlx shadcn@latest add @retab/image-viewer
This pulls in utif for the TIFF path. It runs inside the bundled Web Worker, so
the parser stays out of your main bundle entirely. Plain images (PNG, JPEG, WebP,
GIF, AVIF) use only the browser's native decoders and never load utif.
Usage
import { ImageViewer } from "@/components/ui/image-viewer"
export function Example() {
return <ImageViewer src="/scan.tiff" className="h-[600px]" />
}Pass any image URL — a single .png/.jpg/.webp renders as one frame, a
multi-page .tiff renders as a scrollable stack of frames.
Bounding-box overlays
renderPageOverlay runs per frame and receives the rendered frame size, so
normalized boxes map with simple percentages:
<ImageViewer
src={url}
renderPageOverlay={({ pageNumber }) =>
fieldsOnFrame(pageNumber).map((f) => (
<div
key={f.key}
className="absolute outline outline-2 outline-indigo-500"
style={{
left: `${f.bbox.left * 100}%`,
top: `${f.bbox.top * 100}%`,
width: `${f.bbox.width * 100}%`,
height: `${f.bbox.height * 100}%`,
}}
/>
))
}
/>How it performs
- Off-thread decode. Plain images decode via
createImageBitmap(already off-thread); TIFF frames decode in a Web Worker that transfers the resultingImageBitmapback. Either way the heavy work and the pixels stay off the main thread, so scrolling never blocks on decode. - Lazy frames. Each frame reserves its box from the TIFF's IFD dimensions up front, so scroll height is correct without decoding anything. Pixels decode only when a frame nears the viewport.
- Bounded memory. Decoded bitmaps past a small cap are closed (and re-decoded cheaply on scroll-back), and the worker frees UTIF's per-frame buffer after each decode — so a 48-page scan holds flat memory across a full scroll instead of accumulating every page's pixels.
Props
The API mirrors the PDF Viewer — pageNumber is a
1-based frame index (a TIFF page; always 1 for a single image).
| Prop | Type | Description |
|---|---|---|
src | string | URL of the image (same-origin or CORS-enabled). PNG/JPEG/WebP/GIF/AVIF or TIFF. |
scale | number | Optional fixed scale; omit to fit frame width to the container. |
toolbar | boolean | Show the zoom/rotate/download toolbar. Defaults to true. |
downloadFileName | string | Filename for the download button. |
renderPageOverlay | (props) => ReactNode | Per-frame overlay; receives { pageNumber, width, height, scale, rotation }. |
onVisiblePageChange | (page: number) => void | Fires with the frame nearest the top of the viewport on scroll. |
onScrollProgressChange | (progress: number) => void | Fires with scroll progress in [0, 1]. |
header | ReactNode | Full-width strip below the toolbar (e.g. a legend). |
aside | ReactNode | Left rail alongside the scrolling frames (e.g. a page ribbon). |
bare | boolean | Drop the outer border/background so the viewer fills its container. |
className | string | Optional class on the viewer shell. |