- Overview
- Anatomy
- Header
- Navigation
- Renderers
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.
The image source loads with React's use() and Suspense. The scroll viewport
projects frames into a persistent virtual canvas, keeps recently rendered frames
inside a pixel budget, prefetches ahead in the scroll direction, and redraws at
high quality once scrolling settles. Each canvas draws from a ref callback and
releases its bitmap on cleanup.
The canvas lifecycle uses React 19 callback-ref cleanup semantics. Use this viewer in React 19 apps, or port the canvas cleanup path to effects before installing it in a React 18 app.
"use client";
import { ImageViewer } from "@/components/ui/image-viewer";
export function ImageViewerDemo() {
return (
<div className="h-[600px] min-h-0">
<ImageViewer
source={{
kind: "url",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
source={{ kind: "url", url: "/scan.tiff", fileName: "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
renderFrameOverlay runs per frame and receives the rendered frame size, so
normalized boxes map with simple percentages.
<ImageViewer
source={{ kind: "url", url, fileName: "scan.tiff" }}
renderFrameOverlay={({ frameNumber }) =>
fieldsOnFrame(frameNumber).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. - Projected frames. Each frame reserves its box from the TIFF's IFD dimensions up front, so scroll height is correct without decoding anything. The viewport patches a persistent virtual canvas on animation frames instead of remounting every visible frame through the parent React tree.
- Rendered-frame retention. Recently visible canvases stay mounted while the retained rendered-pixel budget allows it. Small multi-page scans can jump back to already-rendered pages without paying another full canvas draw.
- Direction-aware prefetch. The visible window decodes first, then the source prefetches the next likely frames in the current scroll direction.
- Bounded memory. Decoded bitmaps past a small cap are closed (and re-decoded cheaply on scroll-back), rendered canvases are evicted by pixel budget, 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.
API Reference
The Image Viewer uses frame vocabulary throughout: a single image has one frame, and a multi-page TIFF has one frame per TIFF page.
| Prop | Type | Description |
|---|---|---|
source | UrlViewerSource | BlobViewerSource | Canonical image source. PNG/JPEG/WebP/GIF/AVIF/BMP/ICO or TIFF. |
scale | number | Optional fixed scale; omit to fit frame width to the container. |
controls | boolean | Show the zoom/rotate/download controls. Defaults to true. |
renderFrameOverlay | (props) => ReactNode | Per-frame overlay; receives { frameNumber, width, height, scale, rotation }. |
onFrameRenderTiming | (timing) => void | Optional profiling callback for frame draw duration, cache status, DPR, and render status. |
onVisibleFrameChange | (frame: 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 controls (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 root element. |
Source
"use client";
import * as React from "react";
import {
createFrameSource,
isTiffBytes,
type FrameDescriptor,
type FrameSource,
} from "@/lib/image-frame-source";
import {
createViewerResource,