PDF Viewer
alphav0.1.5Drop-in PDF reader — toolbar, zoom, selectable text, drag-drop, and a themed context menu. No commercial SDK.
Context
Use anywhere a `File`, URL, `Blob`, or `ArrayBuffer` needs inline rendering — case management, contract review, knowledge bases, asset libraries, e-sign confirmations, attachment viewers. Continuous-scroll layout with native text selection via pdf.js text-layer; clickable embedded links via the annotation-layer.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/pdf-viewerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/pdf-viewer-fixturesPreview
Source: string URL. Drag-drop is on by default — try dragging another PDF onto the viewer to swap it.
Demo source
Usage
Quick start
The simplest case: pass a URL. The viewer renders a continuous-scroll document with toolbar, zoom, selection, drag-drop, and right-click — all on by default.
import { PdfViewer } from "@/components/pdf-viewer"
export function Example() {
return (
<div className="h-160">
<PdfViewer source="/docs/manual.pdf" />
</div>
)
}The viewer fills its parent container — give it explicit height (here h-160 = 640px). It does not impose a default size.
Sources
Accepts URL strings, File, Blob, ArrayBuffer, and Uint8Array. Drag-and-drop is on by default — drop a PDF onto the viewer and it loads.
<PdfViewer source="/path.pdf" /> // string URL
<PdfViewer source={file} /> // File from <input>
<PdfViewer source={blob} /> // Blob (e.g. fetch().blob())
<PdfViewer source={arrayBuffer} /> // ArrayBuffer
// Empty viewer with drag-drop:
<PdfViewer />Toolbar customization
Three options. Default toolbar; full replacement via renderToolbar; or compose from the standalone parts inside your own layout.
// 1. Default
<PdfViewer source={url} />
// 2. Toolbar off (minimal embed)
<PdfViewer source={url} toolbar={false} />
// 3. Full replacement
<PdfViewer
source={url}
renderToolbar={({ page, numPages, scale, actions }) => (
<MyToolbar
page={page}
total={numPages}
onPrev={actions.goToPrevPage}
onNext={actions.goToNextPage}
/>
)}
/>
// 4. Mix the standalone parts (read viewer context internally)
import {
PdfViewer,
PdfPageNav,
PdfPageIndicator,
PdfZoomControls,
} from "@/components/pdf-viewer"
<PdfViewer
source={url}
renderToolbar={() => (
<div className="flex items-center gap-2 px-3 py-2">
<PdfPageNav />
<PdfPageIndicator />
<PdfZoomControls />
</div>
)}
/>Imperative control (ref)
For external "jump to page" buttons, deep-linked routes, or any control surface outside the viewer.
const ref = useRef<PdfViewerHandle>(null)
<button onClick={() => ref.current?.actions.goToPage(12)}>
Open page 12
</button>
<PdfViewer ref={ref} source={url} />Selection + right-click
Text selection works natively via pdf.js's text-layer. The onSelection callback fires (debounced) when the user changes their selection. Wire onSearchSelectionto surface a "Search selection" item in the right-click menu.
<PdfViewer
source={url}
onSelection={({ text }) => setQuoted(text)}
onSearchSelection={({ text }) => router.push(`/search?q=${text}`)}
/>Password-protected PDFs
When the source is encrypted, the viewer renders a default Dialog asking for the password. Pass a known password via the password prop to skip the prompt; or replace the prompt UI via renderPasswordPrompt.
// Pre-supplied password
<PdfViewer source={url} password="secret" />
// Custom prompt UI
<PdfViewer
source={url}
renderPasswordPrompt={({ submit, error, attempts }) => (
<MyVaultDialog onUnlock={submit} error={error} attempts={attempts} />
)}
/>Worker hosting
pdf.js requires a Web Worker. The viewer bundles pdfjs-dist/build/pdf.worker.min.mjs via new URL(..., import.meta.url) — handled natively by Webpack 5, Turbopack, and Vite. Override via workerSrc if you self-host the worker file.
<PdfViewer source={url} workerSrc="/static/pdf.worker.min.mjs" />Lazy loading
The PDF engine is heavy (~700 KB minified). On routes that don't always render a PDF, dynamic-import the component so the bundle ships only when needed.
import dynamic from "next/dynamic"
const PdfViewer = dynamic(
() => import("@/components/pdf-viewer").then(m => m.PdfViewer),
{ ssr: false }
)Permissions (UX-only)
allowDownload and allowPrint hide the corresponding UI and suppress the keyboard shortcuts. They are nota security mechanism — anyone with browser dev tools can still extract the PDF. Use for "preview-only" surfaces in DRM-light contexts.
Notes
- Continuous scroll only. No paged-mode toggle in this version.
- Image selection / extraction is out of scope today (pdf.js renders pages to a single canvas — individual images aren't DOM nodes).
- Auto-virtualization engages at
≥ 50pages. Override viavirtualize+virtualizeThreshold. - Cross-origin URLs need CORS headers on the server. Without them, pdf.js fails the load and the error state shows the message.
- Print renders each page at 2× DPI for sharp output. Memory peaks briefly during the print render.
Features
- Sources: URL / File / Blob / ArrayBuffer
- Drag-and-drop a PDF onto the viewer to open it
- Continuous-scroll page rendering
- Built-in toolbar + renderToolbar slot + standalone toolbar parts
- Ctrl/Cmd + wheel zoom with cursor-anchored scaling
- Pinch-zoom on touch devices via Pointer Events
- Selectable text via pdf.js text-layer; native browser copy
- Right-click context menu (text-aware) with custom slot override
- Auto-virtualization for large PDFs (≥50 pages by default)
- Password-protected PDFs with default Dialog + custom slot
- High-DPI print rendering via hidden iframe
- Theme-aware (light + dark via design tokens)
- Object-shape callbacks (F-cross-12-correct from day one)
- WCAG 2.1 AA — toolbar role, aria-live page indicator, keyboard nav
- v0.1.4 — F-cross-13 path-b sweep: toolbar parts drop `asChild` (Tooltip/DropdownMenu triggers render directly as buttons via `buttonVariants(…)`; ContextMenuTrigger wraps via `className="contents"` — Base-UI consumer primitives lack Slot support). Zero public-API change.