0

PDF Viewer

A provider that bootstraps the PDF engine, and a static render stack for displaying a document.

"use client";

import { PdfViewer } from "@/components/pdf-viewer";

About

The <PdfViewer /> component is built on top of embedpdf, a headless PDF engine running PDFium in a Web Worker.

<PdfViewer /> owns the engine and the plugin registry. <PdfViewerContent /> renders the viewport, scroller, and per-page layers against it. Both read from a shared context, so chrome components like the PDF Toolbar and the PDF Bookmark Sidebar nest anywhere inside the tree and pick up the active document without extra props.

Installation

pnpm dlx shadcn@latest add pdfcn/viewer-content

This installs <PdfViewerContent /> along with <PdfViewer />, the usePdfEngine hook, and the pdfium.wasm binary it depends on.

Usage

import { PdfViewer } from "@/components/pdf-viewer";
import { PdfViewerContent } from "@/components/pdf-viewer-content";
<PdfViewer documents={[{ url: "/sample.pdf" }]} className="h-[720px]">
  <PdfViewerContent />
</PdfViewer>

<PdfViewer /> renders a flex column that fills its parent, so give it a height — either directly, as above, or from a sized container. Without one it collapses to nothing.

It also forwards every div prop, so layout and styling go on the component itself rather than a wrapper element:

<PdfViewer
  ref={viewerRef}
  documents={[{ url: "/sample.pdf" }]}
  className="h-[720px] rounded-lg border"
  aria-label="Quarterly report"
>
  <PdfViewerContent />
</PdfViewer>

Structure

<PdfViewer />flex column · owns the engine, the plugins, and the document context
<PdfToolbar />shrink-0
<div className='flex flex-1 min-h-0'>
Sidebarw-* shrink-0
<PdfThumbnailSidebar />
<PdfBookmarkSidebar />
<PdfSearchPanel />
<PdfAttachmentList />
any one of these
<PdfViewerContent />flex-1 · scrolls
Every page
RenderLayer
TilingLayer
SearchLayer
SelectionLayer
pageLayers — Annotation, Redaction, Capture, yours

Four rules come out of that drawing.

<PdfViewer /> is a flex column. A toolbar and the document stack vertically with no layout work on your part — that's the shape every example above relies on.

Sidebars need a row. A panel beside the document is the one case the default column can't express. With no toolbar, flip the viewer itself:

<PdfViewer documents={[{ url: "/sample.pdf" }]} className="h-[600px] flex-row">
  <PdfBookmarkSidebar className="w-56 shrink-0 border-r" />
  <PdfViewerContent />
</PdfViewer>

With a toolbar, keep the viewer a column and put your own row inside it, so the toolbar spans the full width and the sidebar sits under it:

"use client";

import { PdfBookmarkSidebar } from "@/components/pdf-bookmark-sidebar";

That wrapper needs min-h-0 as well as flex-1. Without it the row takes its height from its content, and a long document pushes the viewer past its container instead of scrolling inside it.

Depth doesn't matter. Every chrome component finds the active document through context, so <PdfToolbar /> and the sidebars work at any nesting level — inside your row, inside a <Sheet />, inside a collapsible panel. Only <PdfViewer /> has to be an ancestor.

Page layers are the one slot inside the document. <PdfViewerContent /> renders its own stack per page and takes no children; anything that has to sit on top of a page — an annotation layer, a highlight, a watermark — goes through pageLayers, which is called once per page with that page's documentId and pageIndex.

Examples

Default

"use client";

import { PdfViewer } from "@/components/pdf-viewer";

Accessibility

The viewport is a scrollable region reachable with Tab; pages scroll with the arrow keys, Page Up, and Page Down without a pointer. Pages render as bitmaps, not text, so the render layer exposes no text to assistive technology — provide a download link or render the text layer for content a user can't get elsewhere. Loading and failure states are announced through <Skeleton role="status"> and <Alert role="alert">.

API Reference

PdfViewer

Bootstraps the engine and registers the required and light plugin tiers — document manager, viewport, scroll, interaction manager, selection, zoom, pan, spread, rotate, render, tiling, and bookmark.

Renders a flex column carrying data-slot="pdf-viewer", and accepts every div prop, including ref — the reliable DOM handle into the viewer. (embedpdf's own components apply their ref after spreading props, silently discarding a passed ref; this wrapper forwards it normally.)

PropTypeDefaultDescription
documentsInitialDocumentOptions[]Documents to load when the engine starts. Read once, at mount.
pluginsPluginBatchRegistrations[]Heavy-tier plugins to opt into, such as annotation, redaction, or capture.
childrenReact.ReactNodeThe render stack and any chrome components.

The wrapper renders in all three states — booting, failed, and ready — so your layout stays put: <Skeleton /> fills it while PDFium boots, <Alert /> replaces it on failure.

embedpdf's plugin registry rejects registrations after initialization, so the plugin list is resolved once, upfront. Pass a stable plugins array — a new array identity on every render remounts the engine and discards scroll and zoom state. documents is frozen at mount for you, so an inline literal is safe there; changing it later has no effect — open a different document with the document manager's openDocumentUrl or openDocumentBuffer instead.

PdfViewerContent

The static render stack: ViewportScroller → per page RotateRenderLayer, TilingLayer, SearchLayer, and SelectionLayer. Accepts every div prop.

PropTypeDefaultDescription
pageLayers(page: { documentId: string; pageIndex: number }) => ReactNodeExtra layers rendered inside each page.

Sized as flex-1 with both min-h-0 and min-w-0 so it claims whatever space <PdfViewer /> has left after any toolbars or panels — on either axis — and scrolls internally rather than growing the shell past its container.

Heavy-tier layers such as <PdfAnnotationLayer /> are deliberately absent, since their plugins aren't registered by default. Add them through pageLayers once you've opted into the plugin:

<PdfViewerContent
  pageLayers={({ documentId, pageIndex }) => (
    <PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
  )}
/>

PDF_PAGE_COLOR

The string "var(--pdf-page, #fff)" — the colour every page is painted, and the colour anything drawn on a page reads against. Exported so custom on-page chrome can match it.

Pages aren't themed with --background or --card, because a page isn't app chrome: paper is white whether the app around it is light or dark, and a themed page would invert underneath ink that didn't. --pdf-page and its --pdf-page-foreground / --pdf-page-muted-foreground companions are the knobs for it — see Recolouring the paper.

usePdfView

Returns { documentId } for the active document, or null until the plugins are ready. Throws when called outside a <PdfViewer />. This is how chrome components find the document without prop drilling.

usePdfEngine

Creates the PDFium worker engine and tears it down on unmount — a thin shim over embedpdf's usePdfiumEngine, which is where the Web Worker and WASM loading are documented in full.

PropTypeDefaultDescription
wasmUrlstring"/pdfium.wasm"URL the PDFium WASM binary is fetched from.

Returns { engine, isLoading, error }. <PdfViewer /> calls this for you; use it directly only when you need a different WASM source or your own bootstrap shell.