Skip to content
ilinxa/pro-ui

PDF Viewer

alphav0.1.6

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

Category: MediaUpdated: 2026-08-19Created: 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
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

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

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

Demo source

demo.tsxtsx
"use client"; import dynamic from "next/dynamic";import { useEffect, useRef, useState } from "react";import { ArrowLeft, ArrowRight, FileText, Search, Upload } from "lucide-react";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Skeleton } from "@/components/ui/skeleton";import { Switch } from "@/components/ui/switch";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { cn } from "@/lib/utils";import {  PDF_VIEWER_DUMMY_URL,  pdfViewerDummyBlob,} from "./dummy-data";import type { PdfViewerHandle, PdfViewerProps } from "./types"; // pdf.js requires browser globals (DOMMatrix, Worker, Canvas), so the viewer// must be loaded client-only. Consumers should use the same pattern in their// own app (see usage.tsx → "Lazy loading"). This wrapper is allowed in// demo.tsx because demo files are docs-site-only — never shipped via the// registry to consumers.const PdfViewer = dynamic(  () => import("./pdf-viewer").then((m) => m.PdfViewer),  {    ssr: false,    loading: () => (      <div className="flex h-full w-full items-center justify-center bg-muted/30">        <Skeleton className="h-3/4 w-2/3" />      </div>    ),  },) as unknown as React.ForwardRefExoticComponent<  PdfViewerProps & React.RefAttributes<PdfViewerHandle>>; function ViewerFrame({  children,  className,}: {  children: React.ReactNode;  className?: string;}) {  return (    <div      className={cn(        "h-160 w-full overflow-hidden rounded-lg border border-border bg-card",        className,      )}    >      {children}    </div>  );} function UrlTab() {  return (    <div className="space-y-3">      <ViewerFrame>        <PdfViewer source={PDF_VIEWER_DUMMY_URL} />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Source: <code>string</code> URL. Drag-drop is on by default — try        dragging another PDF onto the viewer to swap it.      </p>    </div>  );} function FileTab() {  const [file, setFile] = useState<File | null>(null);  return (    <div className="space-y-3">      <div className="flex flex-wrap items-center gap-2">        <label className="inline-flex">          <input            type="file"            accept="application/pdf"            className="sr-only"            onChange={(e) => setFile(e.target.files?.[0] ?? null)}          />          <Button asChild type="button" variant="outline" size="sm">            <span>              <Upload aria-hidden="true" />              Choose a PDF            </span>          </Button>        </label>        {file ? (          <span className="font-mono text-xs text-muted-foreground">            {file.name}          </span>        ) : null}        {file ? (          <Button            type="button"            variant="ghost"            size="sm"            onClick={() => setFile(null)}          >            Clear          </Button>        ) : null}      </div>      <ViewerFrame>        <PdfViewer source={file} />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Source: <code>File</code> from the OS file picker. Same drag-drop        behavior — drop any PDF into the viewer to load it.      </p>    </div>  );} function BlobTab() {  const [blob, setBlob] = useState<Blob | null>(null);  const [error, setError] = useState<string | null>(null);  const [loading, setLoading] = useState(false);   const fetchBlob = async () => {    setLoading(true);    setError(null);    try {      const b = await pdfViewerDummyBlob();      setBlob(b);    } catch (err) {      setError(err instanceof Error ? err.message : String(err));    } finally {      setLoading(false);    }  };   return (    <div className="space-y-3">      <div className="flex flex-wrap items-center gap-2">        <Button          type="button"          size="sm"          variant="outline"          onClick={fetchBlob}          disabled={loading}        >          {loading ? "Fetching…" : "Fetch sample → Blob"}        </Button>        {blob ? (          <span className="font-mono text-xs text-muted-foreground">            Blob: {(blob.size / 1024).toFixed(1)} KB          </span>        ) : null}        {error ? (          <span className="text-xs text-destructive">{error}</span>        ) : null}      </div>      <ViewerFrame>        <PdfViewer source={blob} />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Source: <code>Blob</code>. Useful when you have an in-memory PDF        (after server-side rendering, decryption, etc.).      </p>    </div>  );} function DragDropTab() {  return (    <div className="space-y-3">      <ViewerFrame>        <PdfViewer />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Empty state with drag-drop on. Drag a PDF from your desktop onto the        viewer area to load it. Non-PDF drops are ignored.      </p>    </div>  );} function CustomToolbarTab() {  return (    <div className="space-y-3">      <ViewerFrame>        <PdfViewer          source={PDF_VIEWER_DUMMY_URL}          renderToolbar={({ page, numPages, scale, actions }) => (            <div className="flex items-center gap-3 border-b border-border bg-primary/5 px-4 py-2">              <FileText className="size-4 text-primary" aria-hidden="true" />              <span className="text-sm font-medium text-foreground">                Custom toolbar              </span>              <div className="ml-auto flex items-center gap-1.5">                <Button                  variant="ghost"                  size="sm"                  onClick={() => actions.goToPrevPage()}                  disabled={page <= 1}                >                  <ArrowLeft />                  Prev                </Button>                <span className="font-mono text-xs tabular-nums">                  {page} / {numPages || "—"}                </span>                <Button                  variant="ghost"                  size="sm"                  onClick={() => actions.goToNextPage()}                  disabled={page >= numPages}                >                  Next                  <ArrowRight />                </Button>                <span className="ml-2 font-mono text-xs tabular-nums text-muted-foreground">                  {Math.round(scale * 100)}%                </span>              </div>            </div>          )}        />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Full toolbar replacement via <code>renderToolbar</code>. The slot        receives state + actions; build whatever chrome you want.      </p>    </div>  );} function ToolbarOffTab() {  return (    <div className="space-y-3">      <ViewerFrame>        <PdfViewer source={PDF_VIEWER_DUMMY_URL} toolbar={false} />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        Minimal embed — no toolbar. Ctrl/Cmd+wheel zoom and PgUp/PgDn page        nav still work; right-click context menu still works.      </p>    </div>  );} function PermissionsTab() {  const [download, setDownload] = useState(true);  const [print, setPrint] = useState(false);  return (    <div className="space-y-3">      <div className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-muted/30 px-3 py-2">        <label className="flex items-center gap-2 text-sm">          <Switch checked={download} onCheckedChange={setDownload} />          Allow download        </label>        <label className="flex items-center gap-2 text-sm">          <Switch checked={print} onCheckedChange={setPrint} />          Allow print        </label>      </div>      <ViewerFrame>        <PdfViewer          source={PDF_VIEWER_DUMMY_URL}          allowDownload={download}          allowPrint={print}        />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        UX-level gates only — anyone with browser dev tools can still pull the        PDF. Useful for &quot;preview-only&quot; surfaces.      </p>    </div>  );} function ImperativeRefTab() {  const ref = useRef<PdfViewerHandle>(null);  const [target, setTarget] = useState("3");  return (    <div className="space-y-3">      <div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">        <span className="text-sm">Jump to page</span>        <Input          type="number"          value={target}          onChange={(e) => setTarget(e.target.value)}          className="h-8 w-16 text-center font-mono tabular-nums"        />        <Button          size="sm"          onClick={() => {            const n = Number.parseInt(target, 10);            if (Number.isFinite(n)) ref.current?.actions.goToPage(n);          }}        >          Go        </Button>        <Button          variant="ghost"          size="sm"          onClick={() => ref.current?.actions.zoomIn()}        >          + Zoom        </Button>        <Button          variant="ghost"          size="sm"          onClick={() => ref.current?.actions.zoomOut()}        >          − Zoom        </Button>        <Button          variant="ghost"          size="sm"          onClick={() => ref.current?.actions.rotate(90)}        >          Rotate        </Button>      </div>      <ViewerFrame>        <PdfViewer ref={ref} source={PDF_VIEWER_DUMMY_URL} />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        External controls via <code>ref</code>. The handle exposes the same{" "}        <code>actions</code> the toolbar uses, plus current state and the        underlying <code>pdfDocument</code>.      </p>    </div>  );} function SelectionTab() {  const [text, setText] = useState("");  return (    <div className="space-y-3">      <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">        <div className="mb-1 flex items-center gap-1.5 text-muted-foreground">          <Search className="size-3" aria-hidden="true" />          <span>Last selection</span>        </div>        <p className="font-mono text-[11px] leading-relaxed text-foreground">          {text || (            <span className="text-muted-foreground">              Select text inside the PDF to capture it here.            </span>          )}        </p>      </div>      <ViewerFrame>        <PdfViewer          source={PDF_VIEWER_DUMMY_URL}          onSelection={({ text }) => setText(text)}          onSearchSelection={({ text }) => {            window.alert(`Search "${text.slice(0, 80)}" — wire your own search`);          }}        />      </ViewerFrame>      <p className="text-xs text-muted-foreground">        <code>onSelection</code> fires debounced; right-click → &quot;Search        selection&quot; calls <code>onSearchSelection</code> (when provided).      </p>    </div>  );} export default function PdfViewerDemo() {  // Force a remount when switching tabs to keep memory bounded.  const [tab, setTab] = useState("url");   // Suppress unused-import warnings in case future tabs need it  useEffect(() => {    void tab;  }, [tab]);   return (    <Tabs value={tab} onValueChange={setTab} defaultValue="url">      <SwipeTabsList>        <TabsTrigger value="url">URL</TabsTrigger>        <TabsTrigger value="file">File</TabsTrigger>        <TabsTrigger value="blob">Blob</TabsTrigger>        <TabsTrigger value="dragdrop">Drag &amp; drop</TabsTrigger>        <TabsTrigger value="custom-toolbar">Custom toolbar</TabsTrigger>        <TabsTrigger value="toolbar-off">Toolbar off</TabsTrigger>        <TabsTrigger value="permissions">Permissions</TabsTrigger>        <TabsTrigger value="ref">Imperative ref</TabsTrigger>        <TabsTrigger value="selection">Selection</TabsTrigger>      </SwipeTabsList>       <TabsContent value="url" className="mt-6">        <UrlTab />      </TabsContent>      <TabsContent value="file" className="mt-6">        <FileTab />      </TabsContent>      <TabsContent value="blob" className="mt-6">        <BlobTab />      </TabsContent>      <TabsContent value="dragdrop" className="mt-6">        <DragDropTab />      </TabsContent>      <TabsContent value="custom-toolbar" className="mt-6">        <CustomToolbarTab />      </TabsContent>      <TabsContent value="toolbar-off" className="mt-6">        <ToolbarOffTab />      </TabsContent>      <TabsContent value="permissions" className="mt-6">        <PermissionsTab />      </TabsContent>      <TabsContent value="ref" className="mt-6">        <ImperativeRefTab />      </TabsContent>      <TabsContent value="selection" className="mt-6">        <SelectionTab />      </TabsContent>    </Tabs>  );} 

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

  • v0.1.6 — the `renderContextMenu` slot opens on right-click at the pointer with a working `closeMenu` (Escape closes); it previously rendered unconditionally in the corner and could not be dismissed
  • 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