GitHub

Image

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.

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.

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 resulting ImageBitmap back. 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.

PropTypeDescription
sourceUrlViewerSource | BlobViewerSourceCanonical image source. PNG/JPEG/WebP/GIF/AVIF/BMP/ICO or TIFF.
scalenumberOptional fixed scale; omit to fit frame width to the container.
controlsbooleanShow the zoom/rotate/download controls. Defaults to true.
renderFrameOverlay(props) => ReactNodePer-frame overlay; receives { frameNumber, width, height, scale, rotation }.
onFrameRenderTiming(timing) => voidOptional profiling callback for frame draw duration, cache status, DPR, and render status.
onVisibleFrameChange(frame: 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 controls (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 root element.

Source

image-viewer.tsx