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.
<PdfViewer /> touches window and Worker on mount, so it can only render
on the client. Load it through next/dynamic(() => import(...), { ssr: false }) when using it inside a Server Component tree, as the demo above does.
Installation#
pnpm dlx shadcn@latest add @pdfviewcn/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#
Four rules cover every layout you can build with it.
<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";Password-protected#
An encrypted document can't be decoded until the password arrives, so <PdfViewerContent /> swaps the render stack for an unlock form instead of reporting a failure. Nothing to wire. The fixture below opens with secret.
"use client";
import { PdfPageNavigation } from "@/components/pdf-page-navigation";This is the user password from PDF's standard security handler (ISO 32000-1 §7.6). An owner password opens the document too, but nothing yet uses it to lift the /P restrictions the restricted demos honour.
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">.
The unlock form focuses its field on mount and again after a rejection, with the typed value selected so a correction replaces it. The rejection message is a role="alert" region tied to the field through aria-describedby, and the field carries aria-invalid while it stands.
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, which embedpdf's own components spread away. This is the reliable DOM handle into the viewer.
| Prop | Type | Default | Description |
|---|---|---|---|
documents | InitialDocumentOptions[] | — | Documents to load when the engine starts. Read once, at mount. |
plugins | PluginBatchRegistrations | [] | Heavy-tier plugins to opt into, such as annotation, redaction, or capture. |
children | React.ReactNode | — | The render stack and any chrome components. |
The wrapper renders in all three states, so your layout stays put: <Skeleton /> while PDFium boots, <Alert /> on failure, the tree when it's ready.
Declare plugins outside the component. embedpdf's plugin
registry
is resolved once, so 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 does nothing — open another document with the document manager's openDocumentUrl or openDocumentBuffer.
PdfViewerContent#
The static render stack: Viewport → Scroller → per page Rotate → RenderLayer, TilingLayer, SearchLayer, and SelectionLayer. Accepts every div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
pageLayers | (page: { documentId: string; pageIndex: number }) => ReactNode | — | Extra 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} />
)}
/>If the surface can make comments, <PdfCommentLayer /> goes here instead — the same layer with the composer, the pin, and the thread already wired in.
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.
A page isn't app chrome, so it doesn't follow --background or --card: paper stays white whether the app around it is light or dark. To change it, set --pdf-page and its --pdf-page-foreground / --pdf-page-muted-foreground companions — 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.
| Prop | Type | Default | Description |
|---|---|---|---|
wasmUrl | string | "/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.