PDF Viewer
alphav0.1.6Drop-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 initpnpm dlx shadcn@latest add @ilinxa/pdf-viewerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/pdf-viewer-fixturesCLI 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
"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 "preview-only" 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 → "Search selection" 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 & 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
≥ 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
- 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.