npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pageindex/pdf-viewer

v0.4.0

Published

React PDF viewer built on pdf.js 5.x with block-level highlighting and virtualized rendering

Readme

@pageindex/pdf-viewer

npm version license

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 BBox highlights 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 aggregate usePdfContext
  • 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-dist

react, 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:

  • goToPage queues until the viewer is ready.
  • getCurrentPage / getTotalPages return 0 before load.
  • scrollToRegion is a no-op before load; clearHighlight is always safe.
  • setRotation only accepts multiples of 90.
  • setZoom is clamped by pdf.js.
  • setZoomMode switches 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.