Mischief

37 / Documents

PDF Viewer

Page-by-page PDF rendering on a canvas, with paging and zoom, over any loader you give it.

—

100%

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/pdf-viewer
import { PdfViewer } from "mischief-ui/pdf-viewer"
Also installs
  • pdfjs-dist
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/pdf-viewer/pdf-viewer.tsx
"use client" import * as React from "react"import {  ChevronLeft,  ChevronRight,  TriangleAlert,  ZoomIn,  ZoomOut,} from "lucide-react"import { cn } from "@/lib/utils" export type PdfPageHandle = {  width: number

Usage

export function Contract() {
  return <PdfViewer source="/agreement.pdf" workerSrc={workerUrl} />
}

The worker

pdf.js renders on a background worker, and it cannot find that worker on its own once your code has been bundled. This is the one thing that reliably goes wrong: without workerSrc the viewer fails at the first document, usually with a message about a missing or mismatched worker.

Point it at a copy of the worker you serve yourself. Copy the file out of pdfjs-dist at build time rather than linking a CDN, so the worker version can never drift from the library version.

// scripts/copy-pdf-worker.mjs
import { copyFile } from "node:fs/promises"
import { createRequire } from "node:module"

const require = createRequire(import.meta.url)
const worker = require.resolve("pdfjs-dist/build/pdf.worker.min.mjs")

await copyFile(worker, "public/pdf.worker.min.mjs")
Run it from your build script, then pass workerSrc="/pdf.worker.min.mjs".

Bringing your own loader

pdfjs-dist is an optional peer, imported dynamically the first time a document opens. Supply loader and it is never imported at all, which is how you swap in your own renderer, reuse a document you already have open, or keep the dependency out of the build entirely.

<PdfViewer
  document={openedElsewhere}
  loader={async (source) => myPdfLibrary.open(source)}
/>

Pass document when you already hold an open handle. The loader is skipped and the viewer renders straight from it.

What a canvas cannot do

Pages are painted to a canvas, so the words in them are pixels. Nothing on the page can be selected, copied, searched with find-in-page, or read by a screen reader, and no amount of ARIA changes that.

When the text has to be reachable, pair the viewer with something that carries it: a text layer positioned over the canvas, an extracted transcript beside it, or a link to download the original. Treat this as a requirement rather than an enhancement if the document is the content of your page.

API

sourcestring | ArrayBufferThe document to open.
documentPdfDocumentHandleAn already open document. Skips the loader.
loader(source) => Promise<PdfDocumentHandle>Replaces the default loader. Supply this and pdfjs-dist is never loaded.
page, defaultPage, onPageChangenumber, number, (page: number) => voidThe current page, controlled or uncontrolled.
defaultScale, minScale, maxScalenumberZoom range. Defaults to 1, 0.5, and 3.
workerSrcstringThe pdfjs worker URL, which most bundlers need set explicitly.

Accessibility

The canvas carries an accessible name naming the document and the page it is showing, and the page counter is a polite live region so moving through a document is announced. Paging and zoom controls are disabled at their limits rather than silently doing nothing. A canvas cannot expose the text underneath it, so pair this with a text layer or a downloadable original when the content has to be readable.