GitHub

PDF

A pdfjs-backed PDF viewer with explicit viewer composition, zoom, rotate, fit-to-width, download, and per-page overlays.

The PDF Viewer renders a document with pdf.js directly — no react-pdf wrapper. It does continuous-scroll rendering, zoom, rotate, fit-to-width, and download, and exposes a per-page overlay callback so you can draw bounding-box citations (edit fields, extraction sources) on top of the rendered page.

Document and page loading use React's use() with Suspense, and each page renders into its canvas from a ref callback (with a cleanup that cancels the in-flight render). Only pages near the viewport hold a live canvas — the rest stay as sized placeholders (tracked by an IntersectionObserver) so a long PDF doesn't rasterize every page at once, while scroll height stays correct.

nvidia-10k-fy2024.pdf

Installation

pnpm dlx shadcn@latest add @retab/pdf-viewer

This pulls in pdfjs-dist. The worker is resolved from the installed package by your bundler (Vite, webpack, or Turbopack) — there is no runtime CDN call.

Viewer composition

<FileViewerProvider
  source={{ kind: "url", url, fileName: "document.pdf" }}
  defaultSidebarOpen
>
  <FileViewer>
    <PdfViewerProvider>
      <FileViewerHeader>
        <FileViewerSidebarTrigger />
        <FileViewerTitle />
        <FileViewerControls />
      </FileViewerHeader>
      <FileViewerContent>
        <FileViewerSidebar aria-label="PDF pages">
          <PdfViewerThumbnails />
        </FileViewerSidebar>
        <FileViewerInset>
          <FileViewerViewport>
            <PdfViewerPages />
          </FileViewerViewport>
        </FileViewerInset>
      </FileViewerContent>
    </PdfViewerProvider>
  </FileViewer>
</FileViewerProvider>

The hierarchy is visible in JSX: FileViewer* parts own file layout, the PDF provider owns shared PDF state, the file viewer header owns title, metadata, and controls, and PdfViewerPages renders the document pages. When PdfViewerProvider is inside FileViewer, it reuses the file viewer resource; standalone PDF provider compositions can still pass source directly. PdfViewerThumbnails is the provider-bound thumbnail adapter. It reads the resource, current page, and page-jump command from PdfViewerProvider.

Keep PdfViewerPages inside FileViewerViewport, and keep that viewport inside FileViewerInset. Fit-to-width scale, virtual page height, and scroll anchoring consume the inset's document-frame contract. If a PDF page list is mounted in a raw flex surface, the pane can resize before the PDF page geometry updates.

The PDF renderer keeps that contract at the layout boundary. Resource lifetime, layout/scale, controls, scroll/runtime, and paint each live in separate adapter modules, so PdfViewerPages stays an orchestration layer rather than a format-specific shell.

For non-provider compositions, use the controlled rail directly:

<PdfThumbnailRail
  resource={resource}
  currentPage={currentPage}
  onSelectPage={scrollToPage}
  thumbnailWidth={120}
  thumbnailShape="page"
/>

thumbnailWidth controls the thumbnail image width. FileViewerSidebar width controls the sidebar shell. thumbnailShape preserves page aspect with "page" or crops each preview into a square frame with "square".

Usage

PdfViewer is the easy API. It is the preassembled PDF viewer with FileViewerHeader, FileViewerContent, FileViewerInset, and PdfViewerPages. Compose PdfViewerThumbnails inside FileViewerSidebar when a thumbnail rail is needed.

import { PdfViewer } from "@/components/ui/pdf-viewer";
 
export function Example() {
  return (
    <PdfViewer
      source={{
        kind: "url",
        url: "/document.pdf",
        fileName: "document.pdf",
      }}
      className="h-[600px]"
    />
  );
}

You can compose workflow chrome around the PDF surface with the same primitives:

<FileViewerProvider
  source={{ kind: "url", url, fileName: "document.pdf" }}
  defaultSidebarOpen
>
  <FileViewer>
    <PdfViewerProvider>
      <FileViewerHeader>
        <FileViewerTitle />
        <FileViewerControls />
      </FileViewerHeader>
      <FileViewerContent>
        <FileViewerSidebar aria-label="Workflow pages">
          <PageRibbon />
        </FileViewerSidebar>
        <FileViewerInset>
          <FileViewerViewport>
            <PdfViewerPages bare className="h-full" />
          </FileViewerViewport>
        </FileViewerInset>
      </FileViewerContent>
    </PdfViewerProvider>
  </FileViewer>
</FileViewerProvider>

Bounding-box overlays

renderPageOverlay runs per page and receives the rendered page size, so normalized boxes map with simple percentages:

<PdfViewer
  source={{ kind: "url", url, fileName: "document.pdf" }}
  renderPageOverlay={({ pageNumber }) =>
    fieldsOnPage(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}%`,
        }}
      />
    ))
  }
/>

The exported PdfHighlight component is a percentage-positioned box for use inside renderPageOverlay (the overlay layer is already position: absolute): <PdfHighlight area={{ left, top, width, height }} />, each field a percentage [0, 100] of the page. Style it via className/style.

Scale control

When scale is omitted, the viewer fits the page width to its container until the user manually zooms. Use defaultScale to seed that uncontrolled zoom state, or pass scale with onScaleChange to control zoom from a parent.

In composed file viewers, fit-to-width uses the nearest FileViewerInset document frame. During inline sidebar transitions, the visible page boxes follow that frame immediately; PDF.js canvas resolution can catch up separately.

const [scale, setScale] = React.useState<number | null>(1)
 
<PdfViewer
  source={{ kind: "url", url, fileName: "document.pdf" }}
  scale={scale ?? undefined}
  onScaleChange={setScale}
/>

Controls zoom buttons call onScaleChange in controlled mode. The fit-width button reports null.

Imperative handle

A ref exposes PdfViewerHandle: scrollToPage(pageNumber, options) scrolls to a page, scrollToPageArea({ pageNumber, top }, options) scrolls a page's normalized vertical position (top, a percentage [0, 100]) into view, and getViewportElement() returns the scrolling viewport. Overlay boxes still use full { left, top, width, height } geometry; imperative area scrolling only needs the vertical target.

API Reference

PropTypeDescription
sourceUrlViewerSource | BlobViewerSourceCanonical PDF source. URL sources preserve PDF.js range-loading behavior.
scalenumberControlled rendered scale. Omit for fit-to-width/uncontrolled zoom.
defaultScalenumberInitial uncontrolled scale. Omit for fit-to-width.
fallbackPageSize{ width: number; height: number }Known first-page geometry for an exact loading skeleton before PDF metadata resolves.
onScaleChange(scale: number | null) => voidFired when zoom controls request a scale change; null means fit width.
controlsbooleanShow the zoom/rotate/download controls. Defaults to true.
renderPageOverlay(props: PageOverlayProps) => ReactNodePer-page overlay; receives { pageNumber, width, height, scale, rotation }.
onVisiblePageChange(page: number) => voidFired with the 1-based page nearest the top of the viewport.
onScrollProgressChange(progress: number) => voidFired with scroll progress in [0, 1].
barebooleanDrop the outer border/background so the viewer fills its container.
classNamestringOptional class on the viewer root element.

Source

pdf-viewer.tsx