- PDF Viewer
- PDF Toolbar
- PDF More Actions Menu
- PDF Floating Toolbar
- PDF Document Tabs
- PDF View
- PDF Document Grid
- PDF Document Info
- PDF Page Navigation
- PDF Zoom Controls
- PDF Undo/Redo Buttons
- PDF Keyboard Shortcuts
- PDF Bookmark Sidebar
- PDF Thumbnail Sidebar
- PDF Search Panel
- PDF Attachment List
- PDF Annotation Layer
- PDF Annotation Toolbar
- PDF Annotation Inspector
- PDF Annotation Selection Menu
- PDF Annotation Sidebar
- PDF Comment
- PDF Comment Draft
- PDF Comment Button
- PDF Comment Sidebar
- PDF Redaction Toolbar
- PDF Capture Button
- PDF Form Fill Toggle
- PDF Signature Button
- PDF Stamp Button
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 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#
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.)
| 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 — 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: 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} />
)}
/>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.
| 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.