PDF Viewer
EmbedPDF-backed PDF reader with toolbar, search, thumbnails, and self-hosted WASM support.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/pdf-viewer.
PDF Viewer renders a PDF from a URL, Blob, or File with page navigation, search, thumbnails, zoom, rotate, and download. It is the embedded reader used by Document Preview for PDF overlays. Demo PDFs are local same-origin fixtures pinned from Extend UI commit bca60b9e204ef0db248466dfa44367ad4c23d404.
When To Use
- Embed a read-only PDF inside a page, drawer, or custom layout.
- You need search, thumbnails, and text selection against PDFium (EmbedPDF).
- You want imperative page jump or per-page overlays for review workflows.
- Prefer Document Preview when the primary UX is a modal/drawer overlay rather than an always-visible embed.
- Use DOCX Viewer for Word documents — this component is PDF-only.
Features
| Area | Behavior |
|---|---|
| Source | src: string | Blob | File; object URLs revoked when owned by f-ui |
| Auth | Forward requestOptions.headers / credentials for remote URLs |
| Engine | EmbedPDF 2.14.4 + PDFium WASM; optional engineOptions.wasmUrl for self-hosting |
| Chrome | Toolbar with zoom, rotate, download, optional Expand, search, and thumbnail sidebar when showToolbar |
| Overlay | renderPageOverlay, pointer handlers, onActivePageChange |
| Handle | scrollToPage, scrollToPageArea, getViewportElement |
| Password | password for protected PDFs; invalid passwords surface a recoverable error |
| i18n | Built-in en / zh-CN via usePdfViewerI18n |
Self-Hosted WASM
This docs site copies pdfium.wasm to /vendor/pdfium.wasm in predev / prebuild. Pass engineOptions={{ wasmUrl: "/vendor/pdfium.wasm" }} (or your CDN path) so EmbedPDF does not rely on a hard-coded f-ui CDN. Omit wasmUrl to use EmbedPDF’s version-matched default.
First load can feel slow
Each PdfViewer instance loads and compiles PDFium WASM (~4.4 MB). That cold start is expected once per mounted viewer; it is not the tiny demo PDF. Docs demos defer mount until near the viewport so a refresh does not initialize every example at once. pnpm dev is slower than production because Vite still optimizes EmbedPDF on first hit.
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/pdf-viewerFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/pdf-viewerFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/pdf-viewerFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/pdf-viewerregistryDependencies: document-internals, shadcn button, input, popover, select, separator, tooltip, and fui-i18n. Runtime: EmbedPDF packages (@embedpdf/* 2.14.4), lucide-react, pdf-lib.
Copy a PDFium WASM asset into your public folder (or CDN) and point engineOptions.wasmUrl at it. Strict CSP must allow worker-src blob: — EmbedPDF 2.14.4 creates blob workers; there is no supported custom worker URL prop.
Usage
import { PdfViewer } from "@/components/f-ui/pdf-viewer/pdf-viewer";
export function Page() {
return (
<div className="h-[560px]">
<PdfViewer
src="/docs/sample.pdf"
engineOptions={{ wasmUrl: "/vendor/pdfium.wasm" }}
/>
</div>
);
}Examples
Default Embed
Loads the pinned local loan-application PDF with the self-hosted WASM path used by this repository’s browser smoke.
"use client";
import { PdfViewer } from "@/components/f-ui/pdf-viewer/pdf-viewer";
import { DocumentDemoWhenVisible } from "@/demos/document-demo-when-visible";
/** Local fixture pinned from Extend UI `bca60b9e204ef0db248466dfa44367ad4c23d404`. */
const DEMO_PDF = "/demo-documents/loan-application.pdf";
export function PdfViewerDemo() {
return (
<DocumentDemoWhenVisible>
<div className="h-[560px] overflow-hidden rounded-xl border">
<PdfViewer
engineOptions={{ wasmUrl: "/vendor/pdfium.wasm" }}
fileName="loan-application.pdf"
src={DEMO_PDF}
/>
</div>
</DocumentDemoWhenVisible>
);
}Overlay Callback and Imperative Jump
renderPageOverlay draws per-page chrome; ref.scrollToPage jumps to a 1-based page. Watch onActivePageChange as you scroll or jump.
"use client";
import { useRef, useState } from "react";
import {
PdfViewer,
type PdfViewerHandle,
} from "@/components/f-ui/pdf-viewer/pdf-viewer";
import { Button } from "@/components/ui/button";
import { DocumentDemoWhenVisible } from "@/demos/document-demo-when-visible";
/** Local fixture pinned from Extend UI `bca60b9e204ef0db248466dfa44367ad4c23d404`. */
const DEMO_PDF = "/demo-documents/loan-application.pdf";
export function PdfViewerOverlayJumpDemo() {
const viewerRef = useRef<PdfViewerHandle>(null);
const [activePage, setActivePage] = useState(1);
const [numPages, setNumPages] = useState(0);
return (
<DocumentDemoWhenVisible minHeight={620}>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => viewerRef.current?.scrollToPage(1)}
type="button"
variant="outline"
>
Jump to page 1
</Button>
<Button
disabled={numPages < 1}
onClick={() =>
viewerRef.current?.scrollToPage(Math.min(numPages || 1, 2))
}
type="button"
variant="outline"
>
Jump to page 2
</Button>
<p className="text-muted-foreground text-sm tabular-nums">
Active page: {activePage}
{numPages > 0 ? ` / ${numPages}` : ""}
</p>
</div>
<div className="h-[560px] overflow-hidden rounded-xl border">
<PdfViewer
ref={viewerRef}
engineOptions={{ wasmUrl: "/vendor/pdfium.wasm" }}
fileName="loan-application.pdf"
onActivePageChange={setActivePage}
onDocumentLoadSuccess={({ numPages: pages }) => setNumPages(pages)}
renderPageOverlay={({ pageNumber }) => (
<div className="pointer-events-none absolute top-2 left-2 rounded-md bg-background/90 px-2 py-1 text-xs shadow-sm">
Page {pageNumber}
</div>
)}
src={DEMO_PDF}
/>
</div>
</div>
</DocumentDemoWhenVisible>
);
}Headless Usage
usePdfViewer is the view-model for source preparation and retry. It returns state.sourceUrl, loading/error flags, and retry. Mount your own engine chrome; stock toolbar parts are optional.
preparing: no
sourceUrl: /demo-documents/loan-application.pdf
loadError: —
retryKey: 0
"use client";
import { usePdfViewer } from "@/components/f-ui/pdf-viewer/use-pdf-viewer";
/** Local fixture pinned from Extend UI `bca60b9e204ef0db248466dfa44367ad4c23d404`. */
const DEMO_PDF = "/demo-documents/loan-application.pdf";
export function PdfViewerHeadlessDemo() {
const { state, retry } = usePdfViewer({ src: DEMO_PDF });
return (
<div>
<p>
preparing: {state.isPreparingSource ? "yes" : "no"}
</p>
<p>sourceUrl: {state.sourceUrl ?? "—"}</p>
<p>
loadError:{" "}
{state.loadError ? state.loadError.message : "—"}
</p>
<p>retryKey: {state.retryKey}</p>
<button onClick={() => retry()} type="button">
Retry
</button>
</div>
);
}Composition
PdfViewer
├── usePdfViewer (source URL + retry)
└── ExtendPdfEngine (vendored EmbedPDF)
├── PdfViewerToolbar
├── PdfViewerStatus
├── thumbnail sidebar
└── viewport (+ renderPageOverlay)Security
| Threat | Default |
|---|---|
| Untrusted PDF content | Rendered in the engine; treat documents as untrusted input |
| Unsafe download / new-tab URLs | Safe URL policy (relative, http:, https:, owned blob:) |
| Strict CSP | Allow worker-src blob: and WASM fetch from your wasmUrl host |
| Viewer-local upload | Not supported — pass src from your app; no built-in file picker |
Edge Cases & Errors
| Case | Behavior |
|---|---|
| Missing / failed URL | Error surface with Retry |
| CORS / credentials | Forward requestOptions; failures still report load error |
| Password-protected PDF | Pass password; invalid/missing password is recoverable |
Blob / File src | Object URL created and revoked on change/unmount when owned by f-ui |
| Auth without CORS | Prefer fetch→Blob in the app, then pass the Blob as src |
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | Blob | File | (required) | Document source. |
requestOptions | { headers?; credentials? } | — | Forwarded for remote URL loads. |
engineOptions | { wasmUrl?; worker? } | { worker: false } | Self-hosted PDFium WASM URL. worker defaults to false (direct engine) to avoid an EmbedPDF 2.14.4 blob-worker wasmInit race that can leave the viewer stuck loading. |
password | string | — | Password for protected PDFs. |
fileName | string | — | Download / display name hint. |
defaultZoom | number | engine default | Initial zoom scale. |
showToolbar | boolean | true | Shows search + thumbnail chrome. |
showDownload | boolean | true | Download control in the toolbar. |
showExpand | boolean | false | Expand control in the toolbar. Shown only when expandMode === "fullscreen" or onExpand is set. |
expandMode | "callback" | "fullscreen" | "callback" | callback: host handles Expand via onExpand. fullscreen: browser Fullscreen API on the viewer root ( onExpand still wins when provided). |
onExpand | () => void | — | Expand handler — typically open Document Preview. |
showRotateControls | boolean | true | Rotate affordances. |
toolbarActions | ReactNode | — | Extra toolbar actions. |
pageClassName | (pageNumber) => string | undefined | — | Per-page class hook. |
renderPageOverlay | (props) => ReactNode | — | Overlay layer for each page. |
onActivePageChange | (pageNumber) => void | — | 1-based active page. |
onDocumentLoadSuccess | ({ numPages }) => void | — | Fired when the document opens. |
onPagePointerDown/Move/Up/Cancel | pointer handlers | — | Page-scoped pointer events. |
className | string | — | Root class. |
classNames | Partial<Record<PdfViewerSlot, string>> | — | Per-slot classes. |
t / locale | translator / string | — | Per-instance i18n overrides. |
Handle (ref): scrollToPage, scrollToPageArea, getViewportElement.
Slots
| Slot | Applied to |
|---|---|
root | Outer viewer root |
toolbar | Toolbar strip |
status | Loading / error status |
viewport | Scrollable page viewport |
sidebar | Thumbnail sidebar |
Hook
usePdfViewer({ src }) returns { state, retry } where state includes sourceUrl, isPreparingSource, loadError, and retryKey. The stock PdfViewer container wraps this hook and mounts the EmbedPDF engine.