GitHub

Image Viewer

A performant image viewer with native off-thread decode, zoom, rotate, fit-to-width, download, and a per-frame overlay slot — including efficient multi-page TIFF scans.

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 resulting ImageBitmap back. 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 ViewerpageNumber is a 1-based frame index (a TIFF page; always 1 for a single image).

PropTypeDescription
srcstringURL of the image (same-origin or CORS-enabled). PNG/JPEG/WebP/GIF/AVIF or TIFF.
scalenumberOptional fixed scale; omit to fit frame width to the container.
toolbarbooleanShow the zoom/rotate/download toolbar. Defaults to true.
downloadFileNamestringFilename for the download button.
renderPageOverlay(props) => ReactNodePer-frame overlay; receives { pageNumber, width, height, scale, rotation }.
onVisiblePageChange(page: number) => voidFires with the frame nearest the top of the viewport on scroll.
onScrollProgressChange(progress: number) => voidFires with scroll progress in [0, 1].
headerReactNodeFull-width strip below the toolbar (e.g. a legend).
asideReactNodeLeft rail alongside the scrolling frames (e.g. a page ribbon).
barebooleanDrop the outer border/background so the viewer fills its container.
classNamestringOptional class on the viewer shell.