- 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
The per-page annotation layer, with selection chrome you can actually see — square resize handles, round vertex handles, and a themeable outline.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
<PdfAnnotationLayer /> is the surface annotations live on. It draws every annotation for one page, hit-tests clicks against them, and owns the drag, resize, rotate, and vertex-edit gestures — everything between "there is a shape in the PDF" and "you can grab it."
It's embedpdf's <AnnotationLayer> with pdfcn's selection chrome applied. Upstream draws a working but plain selection: a hairline outline and solid dots, all in a fixed #007ACC. On a dense page that reads as part of the document rather than as UI, which is what makes which one is selected? hard to answer at a glance. This wrapper swaps in the direct-manipulation vocabulary people already know from design tools:
- Square handles resize the box; round handles move a single point — a line end, a polygon vertex. Same distinction Figma draws, and it tells you what a handle will do before you touch it.
- Handles are white with a coloured border, not solid fills, so they stay legible over black text, a photo, or a dark highlight alike.
- The outline sits off the artwork, not on top of it, so it never gets mistaken for the annotation's own stroke.
- Group selections get a dashed outline — one selection, many objects, without inspecting the handles to tell.
- A selected comment gets no outline at all — the pin is its own marker and draws its own selected state, so a box around it would be a second answer to the same question. See comment pins.
Everything else is <AnnotationLayer>: same props, same render props, same behaviour. It goes in <PdfViewerContent pageLayers>, and pairs with <PdfAnnotationToolbar /> to arm a tool, <PdfAnnotationInspector /> to edit what's selected, and <PdfAnnotationSelectionMenu /> for actions on the page.
The layer does nothing until AnnotationPluginPackage is registered on the
viewer. Annotation is a heavy-tier plugin, so <PdfViewerContent /> doesn't
render this layer for you — you opt into the plugin, then pass the layer
through pageLayers.
Installation#
pnpm dlx shadcn@latest add pdfcn/annotation-layer
Usage#
Register the annotation plugin.
import { createPluginRegistration } from "@embedpdf/core";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
// Outside the component — a new array identity on every render tears the
// engine down and rebuilds it, losing scroll and zoom state.
const plugins = [createPluginRegistration(AnnotationPluginPackage)];Render the layer through pageLayers.
<PdfViewer
documents={[{ url: "/sample.pdf" }]}
plugins={plugins}
className="h-[720px]"
>
<PdfToolbar>
<PdfAnnotationToolbar />
</PdfToolbar>
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
)}
/>
</PdfViewer>pageLayers is called once per page with that page's documentId and pageIndex — pass both straight through. The layer positions itself absolutely inside the page, so there's no wrapper or sizing to do.
Examples#
Default#
Arm a tool from the toolbar, draw a shape, then click it. Square handles at the corners resize it; the handle above it rotates. Draw a line or an arrow instead and the endpoints become round vertex handles.
"use client";
import { createPluginRegistration } from "@embedpdf/core";Recolouring the chrome#
Selection chrome is drawn in var(--pdf-annotation-selection, #3b82f6). Define that variable anywhere above the viewer and every part of the chrome follows — outline, handle borders, rotation icon, connector:
<PdfViewer
documents={[{ url: "/sample.pdf" }]}
plugins={plugins}
style={{ "--pdf-annotation-selection": "#ec4899" } as React.CSSProperties}
>
{/* ... */}
</PdfViewer>It's its own variable rather than a shadcn theme token on purpose. Selection chrome sits on the page, and a PDF page is paper — it stays white in dark mode. --primary is near-white there, so a themed outline would vanish against the very thing it's outlining. The fallback is Tailwind's blue-500, which is roughly where Figma, PDFSlick, and Acrobat all land for a selection accent.
The reach is wider than the page. The annotations and comments panels tint their selected row from the same variable, so a row and the shape it points at are the same colour by construction — the panel is the selection mirrored into a list, the way Figma's layer panel mirrors its canvas.
Import PDF_ANNOTATION_SELECTION_COLOR if you need the same value in your own chrome — a custom selection menu, a hover state — so it recolours along with everything else.
Recolouring the paper#
The page itself, the fill inside every handle, and the comment pin's card all read from one more trio — the page palette:
| Variable | Fallback | What it colours |
|---|---|---|
--pdf-page | #fff | Pages, handle fills, the pin and its halo, thumbnails, document cards |
--pdf-page-foreground | #171717 | Text drawn on the page |
--pdf-page-muted-foreground | #737373 | Secondary text drawn on the page |
<PdfViewer
documents={[{ url: "/sample.pdf" }]}
plugins={plugins}
style={{ "--pdf-page": "#fdf6e3" } as React.CSSProperties}
>
{/* ... */}
</PdfViewer>It's the shadcn mechanism — a surface, its foreground, and a muted foreground,
as custom properties overridable from any ancestor — applied to a surface
shadcn's own tokens don't describe. --card and --popover describe app chrome,
which is meant to invert with the colour scheme; a page is document content,
and paper is white whether the app around it is light or dark. Point on-page
chrome at --popover and it goes near-black in dark mode on a page that stayed
white. The fallbacks are the values shadcn's light palette resolves to, since
light mode is the one where chrome and paper already agree.
PDF_PAGE_COLOR is exported from
<PdfViewerContent /> for chrome of your own that
has to sit on the page.
Installing the palette#
Every component spells its fallback out inline (bg-(--pdf-page,#fff)), so a
file copied straight out of these docs works without any of this. To make the
whole document palette editable in one place instead, add the theme item. It
defines the three above, --pdf-annotation-selection, and the
search highlights:
pnpm dlx shadcn@latest add pdfcn/theme
It writes them into your :root, and nothing into .dark. That's deliberate:
every other token in a shadcn theme is app chrome, which is meant to invert with
the colour scheme, and these describe document content, which has to hold still
across both. The missing dark block is the point, not an omission. Any component
you install that draws on the page pulls this item in for you.
Overriding a single field#
The chrome props merge over the defaults rather than replacing them, so you can change one field and keep the rest:
// Smaller handles, still white-with-coloured-border and still square.
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
resizeUI={{ size: 8 }}
/>// A thicker outline for a high-contrast build.
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ width: 2, offset: 3 }}
/>Pass component to take over a handle's rendering entirely. It's called as a plain function inside a .map(), not as JSX, so key arrives as an ordinary prop and the element you return has to carry it:
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
resizeUI={{
component: ({ key, style, ...props }) => (
<div key={key} {...props} style={{ ...style, background: "black" }} />
),
}}
/>Comment pins#
A comment's bounding box is 24px square and <PdfCommentPin /> fills it, so the standard outline lands as four corners poking out from behind a round marker — a rendering bug, not chrome. When the sole selected annotation is a comment, this layer sets the outline width to 0 and lets the pin's own accent ring speak for the selection. Every other type keeps its box.
That's decided from the selection rather than per annotation because embedpdf's outline is a layer-wide setting — but it's exact, not a compromise: upstream suppresses the individual outline the moment a second annotation joins the selection (the dashed group box takes over), so at most one annotation is ever drawing it. Reading the rule off that one annotation can't affect anything else on the page.
Your own selectionOutline still wins, since it merges last — pass selectionOutline={{ width: 1 }} to put the box back on comments too.
With a selection menu#
selectionMenu renders once per selected annotation, inside a wrapper the layer positions and counter-rotates over the page. documentId isn't part of the payload, so pass it from the pageLayers scope:
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionMenu={(props) => (
<PdfAnnotationSelectionMenu documentId={documentId} {...props}>
<PdfCommentThreadButton
documentId={documentId}
annotation={props.context.annotation.object}
/>
</PdfAnnotationSelectionMenu>
)}
/>
)}
/>The menu ships with delete; anything else is composed in as children — here a <PdfCommentThreadButton /> for commenting on the selected shape. See PDF Annotation Selection Menu for the rest.
Accessibility#
Selection and transform are pointer-only. Annotations are picked by clicking them on the page, and the handles are drag targets with no keyboard equivalent — that's upstream behaviour, and it's the same in every PDF editor we compared against.
That makes the surrounding chrome the accessible path, not this layer: <PdfAnnotationToolbar /> arms tools from real buttons, and <PdfAnnotationInspector /> edits the selected annotation's colour, stroke, and opacity through labelled form controls rather than direct manipulation. If annotating has to be reachable without a pointer in your app, route users through those.
The chrome itself is decorative — it carries no text and announces nothing — so it's marked up as plain <div>s and left out of the accessibility tree's meaningful content.
API Reference#
PdfAnnotationLayer#
A <div> rendered inside each page, carrying every prop embedpdf's AnnotationLayer accepts.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document this layer draws annotations for. |
pageIndex | number | — | Zero-based page index. |
selectionMenu | AnnotationSelectionMenuRenderFn | — | Rendered over a selected annotation. |
groupSelectionMenu | GroupSelectionMenuRenderFn | — | Rendered over a multi-annotation selection. |
resizeUI | ResizeHandleUI | square, 10px, white | Corner and edge handles that scale the annotation. |
vertexUI | VertexHandleUI | round, 10px, white | Handles that move one point of a line or polygon. |
rotationUI | RotationHandleUI | 22px, with connector | The rotation handle above the bounding box. |
selectionOutline | SelectionOutline | solid, 1px, offset 2 | Outline around a single selected annotation. Width drops to 0 when that annotation is a comment. |
groupSelectionOutline | SelectionOutline | dashed, 1px, offset 3 | Outline around a group selection. |
scale | number | current zoom | Overrides the zoom the layer draws at. |
rotation | number | current rotation | Overrides the page rotation the layer draws at. |
The five chrome props are shallow-merged over pdfcn's defaults, one level deep — rotationUI.border merges too. Anything you don't set keeps the pdfcn value, not embedpdf's; pass component to replace a handle's rendering outright.
Every other prop, including customAnnotationRenderer and annotationRenderers, is forwarded untouched. See embedpdf's annotation plugin docs for those.
PDF_ANNOTATION_SELECTION_COLOR#
The string "var(--pdf-annotation-selection, #3b82f6)" — the colour every piece of chrome is drawn in. Exported so custom chrome can match it.
Handle geometry#
Handle sizes are CSS pixels and don't scale with zoom, so a handle stays the same physical size whether the page is at 50% or 400%. The rotation handle's icon is drawn at 60% of rotationUI.size, so resizing the handle keeps the icon proportional.
rotationUI.showConnector is false upstream, which leaves the handle floating above the box with nothing tying it to what it rotates; pdfcn turns it on. (Upstream's JSDoc claims a default of true — the implementation disagrees, so this is set explicitly rather than relied on.)