Skip to content
ilinxa/pro-ui

PDF Viewer

alphav0.1.5

Drop-in PDF reader — toolbar, zoom, selectable text, drag-drop, and a themed context menu. No commercial SDK.

Category: MediaUpdated: 2026-08-11Created: 2026-05-10Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/pdf-viewer

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/pdf-viewer-fixtures

Preview

Source: string URL. Drag-drop is on by default — try dragging another PDF onto the viewer to swap it.

Demo source

demo.tsxtsx

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 ≥ 50 pages. Override via virtualize + 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.

Tags

pdfviewerdocumentreadermediaattachmentreact-pdfpdfjs

Dependencies

shadcn primitives: button, context-menu, dialog, dropdown-menu, input, separator, skeleton, tooltip
npm peer deps: react-pdf@^10.4.1, pdfjs-dist@5.4.296, lucide-react@^1.11.0