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 pdfviewcn's selection chrome applied. Upstream draws a hairline outline and solid dots in a fixed #007ACC, which on a dense page reads as part of the document rather than as UI. This wrapper swaps in the vocabulary design tools use:
- Square handles resize the box; round handles move a single point — a line end, a polygon vertex. The same distinction Figma draws.
- Handles are white with a coloured border, so they stay legible over black text, a photo, or a dark highlight alike.
- The outline sits off the artwork, so it's never mistaken for the annotation's own stroke.
- Group selections get a dashed outline — one selection, many objects.
- A selected comment gets no outline at all — the pin draws its own selected state. 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 @pdfviewcn/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.
On a surface that can make comments, mount PDF Comment
Layer instead — this layer with the
composer, the avatar pin, and the thread wired in. On its own it draws a
comment the way the PDF spec describes it: a yellow note icon that opens
nothing. Worth checking, since the annotation toolbar's default tool set
includes textComment.
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 because the chrome sits on the page, and a page stays paper-white in dark mode — --primary is near-white there, so a themed outline would vanish against the thing it's outlining. The fallback is Tailwind's blue-500.
The annotations and
comments panels tint their selected row
from the same variable, so a row and the shape it points at always match. Import
PDF_ANNOTATION_SELECTION_COLOR for chrome of your own — a custom selection
menu, a hover state — and 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>Same shape as shadcn's own surface tokens — a surface, its foreground, and a muted foreground — for a surface shadcn doesn't describe. The fallbacks are what shadcn's light palette resolves to, since light mode is 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 @pdfviewcn/theme
It writes them into your :root and nothing into .dark — document tokens hold
still across both schemes, so the missing dark block is deliberate. 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#
Drawing a comment as a pin rather than a note icon is <PdfCommentLayer />'s job. What this layer contributes is the selection treatment underneath it, since the outline is a property of the layer.
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. 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.
The rule reads off the selection rather than each annotation, because embedpdf's outline is a layer-wide setting — safe here, since upstream drops the individual outline the moment a second annotation joins the selection and the dashed group box takes over.
Your own selectionOutline merges last and still wins — 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 — upstream behaviour, and the norm across PDF editors.
The accessible path is the surrounding chrome: <PdfAnnotationToolbar /> arms tools from real buttons, and <PdfAnnotationInspector /> edits colour, stroke, and opacity through labelled form controls. Route users through those where annotating has to work without a pointer.
The chrome itself carries no text and announces nothing, so it's plain <div>s left out of the accessibility tree.
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 pdfviewcn's defaults, one level deep — rotationUI.border merges too. Anything you don't set keeps the pdfviewcn 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; pdfviewcn sets it explicitly rather than relying on the default, whose JSDoc claims true.