f-ui
Components

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

AreaBehavior
Sourcesrc: string | Blob | File; object URLs revoked when owned by f-ui
AuthForward requestOptions.headers / credentials for remote URLs
EngineEmbedPDF 2.14.4 + PDFium WASM; optional engineOptions.wasmUrl for self-hosting
ChromeToolbar with zoom, rotate, download, optional Expand, search, and thumbnail sidebar when showToolbar
OverlayrenderPageOverlay, pointer handlers, onActivePageChange
HandlescrollToPage, scrollToPageArea, getViewportElement
Passwordpassword for protected PDFs; invalid passwords surface a recoverable error
i18nBuilt-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-viewer
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/pdf-viewer
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/pdf-viewer
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/pdf-viewer

registryDependencies: 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.

Scroll to load demo…
"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.

Scroll to load demo…
"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

ThreatDefault
Untrusted PDF contentRendered in the engine; treat documents as untrusted input
Unsafe download / new-tab URLsSafe URL policy (relative, http:, https:, owned blob:)
Strict CSPAllow worker-src blob: and WASM fetch from your wasmUrl host
Viewer-local uploadNot supported — pass src from your app; no built-in file picker

Edge Cases & Errors

CaseBehavior
Missing / failed URLError surface with Retry
CORS / credentialsForward requestOptions; failures still report load error
Password-protected PDFPass password; invalid/missing password is recoverable
Blob / File srcObject URL created and revoked on change/unmount when owned by f-ui
Auth without CORSPrefer fetch→Blob in the app, then pass the Blob as src

API Reference

Props

PropTypeDefaultDescription
srcstring | 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.
passwordstringPassword for protected PDFs.
fileNamestringDownload / display name hint.
defaultZoomnumberengine defaultInitial zoom scale.
showToolbarbooleantrueShows search + thumbnail chrome.
showDownloadbooleantrueDownload control in the toolbar.
showExpandbooleanfalseExpand 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() => voidExpand handler — typically open Document Preview.
showRotateControlsbooleantrueRotate affordances.
toolbarActionsReactNodeExtra toolbar actions.
pageClassName(pageNumber) => string | undefinedPer-page class hook.
renderPageOverlay(props) => ReactNodeOverlay layer for each page.
onActivePageChange(pageNumber) => void1-based active page.
onDocumentLoadSuccess({ numPages }) => voidFired when the document opens.
onPagePointerDown/Move/Up/Cancelpointer handlersPage-scoped pointer events.
classNamestringRoot class.
classNamesPartial<Record<PdfViewerSlot, string>>Per-slot classes.
t / localetranslator / stringPer-instance i18n overrides.

Handle (ref): scrollToPage, scrollToPageArea, getViewportElement.

Slots

SlotApplied to
rootOuter viewer root
toolbarToolbar strip
statusLoading / error status
viewportScrollable page viewport
sidebarThumbnail 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.

On this page