- Overview
- Anatomy
- Header
- Navigation
- Renderers
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.
"use client";
import {
FileViewer,
FileViewerContent,
FileViewerHeader,
FileViewerTitle,
FileViewerProvider,
FileViewerSidebar,
FileViewerSidebarTrigger,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
| Prop | Type | Description |
|---|---|---|
source | UrlViewerSource | BlobViewerSource | Canonical PDF source. URL sources preserve PDF.js range-loading behavior. |
scale | number | Controlled rendered scale. Omit for fit-to-width/uncontrolled zoom. |
defaultScale | number | Initial 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) => void | Fired when zoom controls request a scale change; null means fit width. |
controls | boolean | Show the zoom/rotate/download controls. Defaults to true. |
renderPageOverlay | (props: PageOverlayProps) => ReactNode | Per-page overlay; receives { pageNumber, width, height, scale, rotation }. |
onVisiblePageChange | (page: number) => void | Fired with the 1-based page nearest the top of the viewport. |
onScrollProgressChange | (progress: number) => void | Fired with scroll progress in [0, 1]. |
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 { cn } from "@/lib/utils";
import {
FileViewer,
FileViewerContent,
FileViewerHeader,
FileViewerControls,
FileViewerTitle,