@pageindex/pdf-viewer
v0.4.0
Published
React PDF viewer built on pdf.js 5.x with block-level highlighting and virtualized rendering
Maintainers
Readme
@pageindex/pdf-viewer
Headless React PDF viewer built on pdf.js 5.x with block-level highlighting and virtualized rendering.
- Three-mode page control: uncontrolled, controlled, or imperative
- Block-level
BBoxhighlights with rotation- and zoom-stable overlays - Headless — no built-in chrome; compose your own toolbar/sidebar/skeleton via granular hooks (
usePdfPage,usePdfZoom,usePdfRotation,usePdfDocument) or the aggregateusePdfContext - Zero runtime CSS dependency — all required styles are inlined, themable via CSS variables
- ESM-only, React 18 and 19 compatible
Installation
pnpm add @pageindex/pdf-viewer pdfjs-distreact, react-dom, and pdfjs-dist are declared as peer dependencies:
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"pdfjs-dist": "^5.5.0"
}Quick Start
import { PdfViewer, configurePdfWorker } from "@pageindex/pdf-viewer";
// Only needed if you render the selectable text layer (default: true).
// Skip this import when you pass `renderTextLayer={false}`.
import "pdfjs-dist/web/pdf_viewer.css";
// Configure once at app startup, BEFORE any <PdfViewer /> mounts.
configurePdfWorker("/static/pdfjs/pdf.worker.min.mjs");
export function Example() {
return (
<div style={{ position: "relative", width: 800, height: 600 }}>
<PdfViewer url="/sample.pdf" />
</div>
);
}<PdfViewer /> requires a positioned parent. The viewer applies
position: absolute; inset: 0 to its container (pdf.js asserts this), so the
parent just needs a fixed or flex-sized box.
Headless composition
There are no PdfViewer.Toolbar / PdfViewer.Skeleton subcomponents — every
piece of chrome is consumer-owned. Read state through the hooks; pass UI as
children (renders as a sibling of the canvas after load, as the loading
slot before load).
import { PdfViewer, usePdfContext } from "@pageindex/pdf-viewer";
function Toolbar() {
const { currentPage, totalPages, zoom, setZoom } = usePdfContext();
return (
<div className="my-toolbar">
<span>{currentPage} / {totalPages}</span>
<button onClick={() => setZoom(zoom * 1.1)}>+</button>
<button onClick={() => setZoom(zoom / 1.1)}>−</button>
</div>
);
}
function Skeleton() {
return <div className="my-skeleton">Loading…</div>;
}
export function Example() {
return (
<div style={{ position: "relative", width: 800, height: 600 }}>
<PdfViewer url="/sample.pdf">
<Toolbar />
{/* Anything rendered here BEFORE load is the loading slot. */}
<Skeleton />
</PdfViewer>
</div>
);
}Worker Configuration
pdf.js executes parsing in a Web Worker. The worker script
(pdf.worker.min.mjs) is not bundled with this package — you serve it
from your app and point configurePdfWorker at the URL.
Recommended: a postinstall script that copies the worker (plus CMaps and
standard fonts) into your app's static directory:
// scripts/copy-pdfjs-assets.mjs
import { cpSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const pdfjsRoot = dirname(fileURLToPath(import.meta.resolve("pdfjs-dist/package.json")));
const dest = "public/static/pdfjs";
mkdirSync(dest, { recursive: true });
cpSync(join(pdfjsRoot, "build/pdf.worker.min.mjs"), join(dest, "pdf.worker.min.mjs"));
cpSync(join(pdfjsRoot, "cmaps"), join(dest, "cmaps"), { recursive: true });
cpSync(join(pdfjsRoot, "standard_fonts"), join(dest, "standard_fonts"), { recursive: true });{ "scripts": { "postinstall": "node scripts/copy-pdfjs-assets.mjs" } }import { configurePdfWorker } from "@pageindex/pdf-viewer";
configurePdfWorker("/static/pdfjs/pdf.worker.min.mjs");configurePdfWorker is idempotent for the same URL; a different URL re-points
pdf.js to the new worker.
API Reference
<PdfViewer />
import type {
PdfViewerProps,
PdfViewerHandle,
ScrollToRegionOptions,
HighlightRenderInfo,
BBox,
} from "@pageindex/pdf-viewer";| Prop | Type | Default | Description |
| -------------------------- | --------------------------------------------- | ------- | --------------------------------------------------------------------------- |
| url | string \| undefined | — | PDF source URL. undefined renders an empty container. |
| className | string | — | Extra class on the outermost container. |
| defaultPage | number | 1 | Initial 1-based page (uncontrolled). Mutually exclusive with currentPage. |
| currentPage | number | — | Controlled 1-based page. |
| renderTextLayer | boolean | true | Render the selectable text layer. |
| renderAnnotationLayer | boolean | false | Render the annotation layer (links, forms). |
| onPageChange | (page: number) => void | — | Fired on user scroll or controlled page change. |
| onTotalPagesChange | (total: number) => void | — | Fired once per loaded document. |
| onScroll | (offset: number) => void | — | Native scrollTop. Fires at native rate — keep the handler cheap. |
| onLoadSuccess | (numPages: number) => void | — | Fired once the document has loaded. |
| onLoadError | (error: Error) => void | — | See Error classification. |
| ref | Ref<PdfViewerHandle> | — | Imperative handle ref (forwardRef). Compatible with React 18 and 19. |
| highlightAutoFadeMs | number | — | Default for ScrollToRegionOptions.autoFadeAfterMs. |
| highlightFadeDurationMs | number | 1000 | Default for ScrollToRegionOptions.fadeDurationMs. |
| highlightTopOffset | number | 0 | Default for ScrollToRegionOptions.topOffset. Wins over the ratio variant. |
| highlightTopOffsetRatio | number | — | Default for ScrollToRegionOptions.topOffsetRatio. |
| highlightResetZoom | boolean | false | Default for ScrollToRegionOptions.resetZoom. |
| enablePinchZoom | boolean | true | 2-finger touch pinch + Ctrl+wheel / trackpad pinch → pdfViewer.updateScale. |
| renderHighlight | (info: HighlightRenderInfo) => ReactNode | — | Replace the default highlight div. Return null to suppress. |
| children | ReactNode | — | Loading slot before load; sibling of the canvas after load. |
Mixing currentPage with ref.goToPage(N) is supported provided the parent
keeps state in sync via onPageChange. Dev mode logs one-shot warnings for
defaultPage + currentPage conflicts and for the first mixed-mode call.
PdfViewerHandle
interface PdfViewerHandle {
goToPage: (page: number) => void;
getCurrentPage: () => number;
getTotalPages: () => number;
scrollToRegion: (page: number, bbox: BBox, options?: ScrollToRegionOptions) => void;
clearHighlight: () => void;
setRotation: (rotation: 0 | 90 | 180 | 270) => void;
setZoom: (scale: number) => void;
setZoomMode: (mode: "auto" | "page-width" | "page-fit" | "page-actual") => void;
}All methods are safe before the document loads:
goToPagequeues until the viewer is ready.getCurrentPage/getTotalPagesreturn0before load.scrollToRegionis a no-op before load;clearHighlightis always safe.setRotationonly accepts multiples of 90.setZoomis clamped by pdf.js.setZoomModeswitches to a pdf.js named zoom mode so pages reflow against the container.
Hooks
All hooks throw synchronously when called outside a <PdfViewer /> subtree.
import {
usePdfPage, // { currentPage, totalPages, setPage }
usePdfZoom, // { zoom, zoomMode, setZoom, setZoomMode }
usePdfRotation, // { rotation, setRotation }
usePdfLayout, // { scrollMode, spreadMode, setScrollMode, setSpreadMode }
usePdfDocument, // { isLoading }
usePdfLoadProgress, // LoadProgress | null
usePdfContext, // aggregate of all of the above
} from "@pageindex/pdf-viewer";The granular hooks subscribe to a single field and rerender only on that
field's change. usePdfContext is convenient but rerenders on any field
change — prefer the granular hooks when you only read part of the state.
interface PdfContextValue {
currentPage: number; // 1-based, 0 before load
totalPages: number; // 0 before load
zoom: number; // 1 = 100%
zoomMode: "auto" | "page-width" | "page-fit" | "page-actual" | "custom";
rotation: 0 | 90 | 180 | 270;
scrollMode: "vertical" | "horizontal" | "wrapped" | "page";
spreadMode: "none" | "odd" | "even";
isLoading: boolean;
loadProgress: { loaded: number; total: number } | null; // null before first onProgress
setPage: (page: number) => void;
setZoom: (zoom: number) => void;
setZoomMode: (mode: "auto" | "page-width" | "page-fit" | "page-actual") => void;
setRotation: (rotation: 0 | 90 | 180 | 270) => void;
setScrollMode: (mode: "vertical" | "horizontal" | "wrapped" | "page") => void;
setSpreadMode: (mode: "none" | "odd" | "even") => void;
}The hooks, the imperative handle, and the controlled currentPage prop are
all kept in lock-step against pdf.js — there is exactly one source of truth.
Coordinate System: BBox
type BBox = [x0: number, y0: number, x1: number, y1: number];| Convention | Choice | Rationale |
| ---------- | ------------------- | --------------------------------------------------------------- |
| Range | Integers 0..1000 | Lossless serialization; matches upstream PageIndex block bbox. |
| Origin | Page's top-left | Same as CSS; avoids viewport / PDF-user-space runtime branching. |
| Y axis | Points down | Same as CSS, opposite to PDF user space. |
| Cross-page | Not allowed | Multi-page regions are a list of (page, bbox) pairs. |
Rotation is delegated to pdf.js's own viewport.convertToViewportRectangle,
so highlights stay stable under any rotation/scale combination.
scrollToRegion(page, bbox, options?)
Scrolls into view and renders a single overlay over the region. The viewer holds at most one highlight at a time; subsequent calls replace it.
interface ScrollToRegionOptions {
/** Fade after N ms. `0` or `undefined` disables auto-fade. */
autoFadeAfterMs?: number;
/** Fade-out transition length. Default: `1000` ms. */
fadeDurationMs?: number;
/** Pixels reserved between viewport top and the highlight (sticky header). */
topOffset?: number;
/** Fraction of `clientHeight` reserved above the highlight. Ignored when `topOffset` is set. */
topOffsetRatio?: number;
/** Reset zoom to `1.0` before scrolling for predictable landing size. */
resetZoom?: boolean;
}Unspecified fields fall back to the matching <PdfViewer highlight…> prop,
then the built-in default. Vertical anchor is the bbox midpoint so the
highlight is not clipped by the viewport's top edge. Zero-area bboxes
(e.g. [500, 500, 500, 500]) degrade to a page-anchor scroll without
rendering an invisible overlay.
Custom highlight DOM
Pass renderHighlight to replace the default overlay div:
<PdfViewer
url="/sample.pdf"
renderHighlight={({ rect, fading, fadeDurationMs }) => (
<div
style={{
position: "absolute",
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
border: "2px solid orange",
opacity: fading ? 0 : 1,
transition: `opacity ${fadeDurationMs}ms ease-out`,
}}
/>
)}
/>rect is in CSS pixels relative to the portal target's content frame —
apply directly with no extra transform.
Error classification
onLoadError receives one of three typed subclasses (all re-exported), with
the raw upstream error preserved as .cause:
| Class | Trigger |
| ------------------ | ----------------------------------------------------------- |
| PdfDownloadError | HTTP 4xx/5xx, missing file, generic fetch failure. Has url and status. |
| PdfDecodeError | Bytes arrived but pdf.js could not parse them. |
| PdfCorsError | Browser blocked the cross-origin fetch. |
if (error instanceof PdfCorsError) {
// Show "switch to a proxied URL" UX
}Theming
Two CSS variables drive every built-in style. Set them on any ancestor:
| Variable | Default | Affects |
| ------------------------ | ------------------------ | ----------------------------- |
| --pdfv-background | rgb(240 240 240) | Viewer gutter background |
| --pdfv-highlight-color | rgb(147 197 253 / 0.5) | Default scrollToRegion fill |
For deeper theming (custom DOM, animations), pass renderHighlight and build
your own toolbar/skeleton with the hooks — the package ships no opinionated
chrome styles to override.
Integration Notes
Next.js (App Router + Turbopack)
Mount the viewer inside a client component ("use client"). pdf.js touches
window synchronously on import and cannot run during SSR.
If you hit "Can't resolve pdfjs-dist/build/pdf.worker.min.mjs" in dev under
Turbopack, add the package to transpilePackages:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
transpilePackages: ["@pageindex/pdf-viewer"],
};
export default config;Serve the worker from public/static/pdfjs/ — Next.js maps everything under
public/ to the site root, so
configurePdfWorker("/static/pdfjs/pdf.worker.min.mjs") resolves correctly
in both dev and production.
Server-side rendering
The viewer is client-only. Wrap usage in a "use client" boundary (Next.js
App Router) or a hydrate-only component (Remix / TanStack Start). Calling
getDocument or instantiating pdf.js's PDFViewer during SSR throws because
pdf.js reaches for window, document, and Worker synchronously.
pdf.js version check
The viewer warns once if the installed pdfjs-dist is older than the
verified-against major.minor (5.5+). The check is advisory — it never
throws — and runs in both dev and production. Exported constants:
import {
checkPdfjsVersion,
EXPECTED_PDFJS_VERSION_MAJOR,
EXPECTED_PDFJS_VERSION_MIN_MINOR,
} from "@pageindex/pdf-viewer";Development
pnpm install
pnpm dev # tsdown --watch
pnpm storybook # localhost:6006
pnpm test # vitest
pnpm typecheck
pnpm lint
pnpm build
pnpm build-storybook # static demo
pnpm e2e # Playwright (against Storybook)Sample PDFs are generated on the fly:
pnpm gen:sample-pdf # → test/fixtures/sample.pdf
pnpm gen:large-pdf # → test/fixtures/large.pdf (50 pages)For contribution guidelines, commit conventions, and the release process, see CONTRIBUTING.md.
License
MIT — same protocol as pdf.js.
