- 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
Contextual style panel for the annotation plugin — position, size, fill, stroke, opacity, text, and whether the mark prints, plus align, distribute, and grouping for a multi-selection.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
<PdfAnnotationInspector /> is the panel that answers "what am I editing, and how do I change it". It follows the selection: with annotations selected it edits those annotations; with nothing selected but a tool armed, it edits that tool's defaults instead — so the same panel sets up the next shape you're about to draw, and there's no separate tool-style surface to hunt for.
Every control appears only when the target actually has that field, and each row is one PDF entry: a square is filled and stroked, so it shows a fill, a stroke colour and a width; a highlight has only /C, so it shows one colour and its opacity. Select more than one and an Arrange section appears on top of that, with the align, distribute, and group controls a design tool would put there.
It pairs with <PdfAnnotationToolbar />: the toolbar picks what you're making, the inspector decides what it looks like.
Annotation is a heavy-tier plugin, which means <PdfViewer /> does not register it for you. The plugin and the layer that draws annotations onto the page both have to be added before this panel has anything to inspect.
The inspector renders nothing at all when the annotation plugin isn't registered. If it never appears, that's the missing registration rather than an empty selection.
Installation#
pnpm dlx shadcn@latest add pdfcn/annotation-inspector
The inspector imports TOOL_ICONS from the toolbar to label the current target, which is why both files land together.
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)];Put the inspector beside the page canvas.
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
<PdfToolbar>
<PdfAnnotationToolbar />
</PdfToolbar>
<div className="flex min-h-0 flex-1">
<PdfViewerContent
className="flex-1"
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
)}
/>
<PdfAnnotationInspector className="w-72 border-l" />
</div>
</PdfViewer>The inspector reads documentId from <PdfViewer> context, so it can sit anywhere inside the viewer — a resizable panel, a Sheet, a collapsible rail — with no prop drilling.
Structure#
Sections, each rendered only when the target has fields for it:
- Layout — position (X, Y) and size (W, H), shown for a single selection. Width and height go through the plugin's own transform, so resizing an ink stroke scales its points and resizing a callout moves its leader line, exactly as the on-page handles do.
- Arrange — align, distribute, and group, shown for a multi-selection.
- Appearance — colour (named "Fill" where there's a second colour to tell it apart from) and opacity.
- Stroke — colour and width.
- Text — font colour and size.
- Output — whether the annotation prints, shown for a live selection.
Every field is one row: its name on the left, its controls on the right — no exceptions, so the panel is scanned rather than read. A control that can't explain itself carries an info affordance beside its label instead of a line of prose beneath it; Print is the one that does. Colour opens a popover holding the plugin's configured presets, a saturation-and-hue picker for anything else, and a hex field under both — see Naming a colour. Numeric properties pair a typed value with a slider, so a stroke of exactly 2 is something you state rather than hunt for — the slider commits once, on release, rather than on every pixel of the drag, for one engine write and one undo entry per adjustment.
A split selection reads as Mixed rather than as one member's value, and colour and number say it the same way: an empty field with the word in placeholder grey. Colour drops its swatch entirely rather than showing the empty checkerboard, which in this panel means None — a value an annotation can actually hold, see No colour. Typing or dragging replaces it for everything selected.
One row is one PDF entry. A square is stroked and filled, so it shows a fill, a stroke colour, and a width; a highlight has only /C, so it shows one colour and its opacity. The plugin reads a markup annotation's single /C back under two field names — strokeColor and a deprecated color — and the panel shows it once, under Stroke where there's a width to make it a stroke and under Appearance where there isn't. Two rows for one entry would be worse than redundant: the engine writes strokeColor ?? color, so edits to the second row land nowhere.
Print is PDF's own Print flag (/F bit 3), not a pdfcn convention: turn it off and the mark stays on screen for reviewers but leaves the paper — the standard way to keep review markup out of a printed copy. Every tool creates annotations with it set, so the switch starts on. It's written per annotation rather than as one shared patch, since /F is a set and a blanket write would overwrite each annotation's own hidden and locked flags.
Examples#
Default#
"use client";
import { createPluginRegistration } from "@embedpdf/core";Editing a tool before you draw#
Arm a tool with nothing selected and the inspector switches to that tool's defaults. Set the colour and stroke width there, and the next shape you draw comes out that way — the same panel, no separate step.
Naming a colour#
The palette answers "one of these". A document routinely wants a colour that came from somewhere else — a brand sheet, a design file, the annotation on the previous page — so the popover holds two more ways to arrive at one. The picker is a saturation and brightness surface with a hue rail beneath it: the two gestures a fixed palette can't offer. The hex field under both is the only one that can be spelled, which is what a colour you were handed rather than chose needs.
Picking a preset doesn't close the popover. It used to, back when the palette was the whole of it; now a preset is most often the nearest colour to start from, and the surfaces under it are how you get from there to the one you wanted.
The hex field takes #RGB and #RRGGBB, with or without the hash; shorthand expands, and the display uppercases as a text transform rather than by rewriting what you typed, so the caret doesn't move for a change you can't see the point of. Enter or blur commits, Esc reverts. Anything that isn't a colour reverts too rather than clearing the field: a half-typed hex is a value in progress, not a request to unset one, and clearing has a control of its own. On a split selection it reads Mixed in placeholder grey, like every other field.
The picker commits on release, so a drag across it is one engine write and one undo entry — the same bargain the opacity and stroke-width sliders strike. Arrow keys move it too, and those commit per press, because a key press is a whole adjustment rather than a step of one. Shift takes the coarse step; hue wraps past either end.
The page doesn't repaint until you let go. updateAnnotation takes a patch
and nothing else, so there's no history-free write to preview with —
live-on-page and one-undo-entry-per-drag can't both be had today. The popover
answers for the page in the meantime: the trigger's chip, its value, and the
hex field all report the colour under the pointer as it moves. If the plugin
grows a transaction or a silent write, this is the trade that stops being
necessary.
No colour#
None leads the palette on the fill row, drawn as the checkerboard the panel uses everywhere for a colour that paints nothing. It isn't an absence the panel invents: PDF states a colour as an array of 0, 1, 3, or 4 numbers, and an array of zero numbers is the spec's own way of saying there is none — no /IC means the shape isn't filled, no /C means it isn't stroked. It's what every shape tool ships with, which is why an unfilled rectangle is the one you get by default. Before, picking a colour was a one-way door.
It's on the fill row and nowhere else, for reasons on both sides. A stroke is the one thing keeping an unfilled shape visible, and the engine's write path for a stroke colour has no clear — it expects a hex — so a None there would be a control that throws rather than one that clears.
Zero is likewise a stroke width the spec defines rather than a floor to clamp: /BS /W 0 means no border shall be drawn. The slider used to start at 1, which quietly gave an unbordered annotation a border the first time anyone touched it.
An annotation with no fill and a zero-width stroke paints nothing, and stays reachable anyway — the plugin's renderers keep a 20px hit target whatever the width, and the annotation is listed in the sidebar regardless. Making something invisible is a thing PDF lets you do; making it unrecoverable isn't.
Multi-selection#
Hold ⌘ (or Ctrl) and click to add an annotation to the selection — that's the plugin's own gesture, and it's the only way in, since there's no marquee upstream.
Selecting several annotations edits all of them at once, showing only the controls their types have in common: a square and a highlight share opacity, so that shows and the square's stroke width doesn't. A field whose values differ reads as Mixed until you set it, which then applies to everything selected.
The fill row is the exception to "in common". It's dropped whenever any member of the selection is one of the markup types whose color is only a mirror of its stroke, because a row that means the interior for half the selection and the stroke for the rest can't be labelled honestly either way. Both still show their one colour, under Appearance.
Arranging a multi-selection#
The Arrange section is the design-tool half of the panel. Six align buttons pull the selection to an edge of its bounding box — left, centres, right, top, middles, bottom — and two distribute buttons spread it evenly along an axis. Distribute needs three annotations: the outermost two pin the span and everything between is re-laid with equal gaps between edges, the same reading as Figma's and Illustrator's.
A structurally locked annotation still counts toward the bounding box but doesn't move, so aligning against a locked stamp pulls the rest to it rather than dragging it along.
Align and distribute work in page coordinates, so they're disabled when the selection spans pages — there's no shared frame to arrange within, and the panel says so. Group isn't gated on that: a group is a relationship rather than a layout, and the plugin lets one straddle pages.
Moves go through the plugin's moveAnnotation, which preserves each
annotation's appearance stream — a rect patch would mark it dirty and
regenerate one, which can visibly redraw a stamp, a signature, or anything
imported. The trade is undo granularity: moveAnnotation registers one
history command per call, so aligning four annotations leaves up to four undo
steps. Annotations already sitting on the target edge are skipped, so the
count is usually lower than the selection size.
Grouping#
Group ties the selection together under its first member, so clicking any one of them afterwards selects the whole set. With a group member selected the button becomes Ungroup, which dissolves the group the member belongs to — that's why the Arrange section still appears for a single selection when it's part of a group.
Grouping is stored the way the PDF spec stores it, as a reply relationship between annotations, so it survives a save and round-trips through other readers.
There's no z-order or reordering API upstream, so there's no "bring to front" here and no drag-to-reorder anywhere in pdfcn. Annotations draw in document order.
When annotating is disabled#
The inspector reads the active document's modify-annotations permission and disables every control — with a line explaining why — when the flag is denied. Position and size are additionally disabled for an annotation carrying the PDF locked flag, which forbids moving it while still allowing a restyle. See Permissions for the full model.
Accessibility#
Every control carries a label naming the field it edits. Those names are short — three sections own a Color and two own a Width — so each section is a group labelled by its own heading, and a screen reader announces "Stroke, group" on the way in. The qualifier comes from the heading that's already on screen rather than from a hidden label repeating it.
A colour trigger is the one control whose visible text is its value, so its name is composed from both: aria-labelledby points at the row's label and then at the value, giving "Color #E44234" or "Color Mixed". An aria-label would have replaced the value rather than prefixing it.
Colour presets are buttons with aria-pressed reflecting the current value, so the selected swatch is exposed without relying on the ring alone, and each is labelled with its colour value. The controls beneath them are named for what they are rather than for the field they share — "Color, saturation and brightness", "Color, hue", "Color, hex" — so a popover of four ways to set one property doesn't announce as four identical controls.
No ARIA role describes a surface carrying two values at once, and splitting the saturation area into two focus stops would describe the markup rather than the gesture. It reports as one slider on saturation and spells the brightness it also holds into aria-valuetext, so the arrow keys that move it vertically announce what they did. The hex field stays the exact way in for anyone who'd rather state a colour than steer to one.
Numeric fields commit on blur or Enter and discard on Escape, so a half-typed value never moves the annotation somewhere it shouldn't go. They're real number inputs, so they keep the spinbutton role, announce their range, and step with the arrow keys — only the native spinner is hidden, with appearance, which is visual and leaves all of that intact. They carry step="any" because PDF user-space coordinates are fractional: the default step="1" would mark a height of 123.6 invalid and expose it to assistive tech as an error.
Sliders are standard range inputs and respond to arrow keys.
The info affordance beside Print is a real button rather than an icon with a title, so it takes focus and its tooltip opens for the keyboard as well as the pointer. The same prose is rendered offscreen and pointed at by the switch's aria-describedby: Base UI's tooltip deliberately doesn't describe its trigger — a tooltip is supplementary, and its popup only exists while open — so without that the sentence would reach everyone except the people a description is for. It's the switch that claims it rather than the button, since the switch is what the sentence is about.
Align and distribute are icon-only, so each carries its name as aria-label as well as in a tooltip. A disabled button swallows hover and takes the tooltip with it, which is why the name lives on the button rather than only in the tooltip.
API Reference#
PdfAnnotationInspector#
A <div> carrying data-slot="pdf-annotation-inspector". Accepts every <div> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | null | The ambient document | Which document's selection to edit. |
Returns null when the document isn't loaded or the annotation plugin isn't registered.
The header carries data-slot="pdf-annotation-inspector-header" and each section data-slot="pdf-annotation-inspector-section".
usePdfAnnotationSelector#
The inspector, the toolbar, and anything else reading annotation state subscribe through this hook rather than through embedpdf's useAnnotation(documentId), which hands back a document's entire annotation state and re-renders on every change to it.
const activeToolId = usePdfAnnotationSelector(
documentId,
(state) => state.activeToolId,
);| Argument | Type | Description |
|---|---|---|
documentId | string | The document whose annotation state to read. |
selector | (state: AnnotationDocumentState) => T | The slice to subscribe to. |
isEqual | (a: T, b: T) => boolean | Defaults to Object.is. |
Pass isEqual whenever the selector builds a new object or array, since otherwise every read produces a fresh reference and React reads it as a change. shallowArrayEqual is exported alongside for the common case.
usePdfAnnotationTool(toolId) is the same mechanism against the plugin's global tools list, for a control editing one tool's defaults. Pass null to opt out.