Contextual style panel for the annotation plugin — position, size, fill, stroke, border style, opacity, blend, text, and whether the mark prints, plus align, distribute, and grouping for a multi-selection.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
<PdfAnnotationInspector /> follows the selection: with annotations selected it edits those annotations; with nothing selected but a tool armed, it edits that tool's defaults — so the same panel sets up the next shape you draw, with no separate tool-style surface to hunt for.
Controls appear only when the target has that field, and each row is one PDF entry. Select more than one and an Arrange section appears on top, 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, so <PdfViewer /> doesn't register it for you. Add the plugin and the layer that draws annotations onto the page 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 @pdfviewcn/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 }) => (
<PdfCommentLayer documentId={documentId} pageIndex={pageIndex} />
)}
/>
<PdfAnnotationInspector className="w-72 border-l" />
</div>
</PdfViewer>The layer is <PdfCommentLayer /> because the toolbar above it can arm textComment; a surface with no comment tool takes <PdfAnnotationLayer /> directly.
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), opacity, and blend mode.
- Stroke — colour, width, border style and its dash, cloudiness, and the endings drawn on a line's two ends.
- Text — font family and style, size, colour, and where the words sit in their box.
- 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. The control follows the shape of the value:
- Colour opens a popover with the plugin's presets, a saturation-and-hue picker, and a hex field — see Naming a colour.
- Numbers pair a typed value with a slider, so a stroke of exactly 2 is something you state rather than hunt for. The slider commits on release: one engine write and one undo entry per adjustment.
- A handful of pictorial values is a row of segmented icons — three glyphs say left, centre, right without being opened first.
- A closed set too long to press — sixteen blend modes, ten line endings — is a dropdown, since there's no glyph anyone reads as Luminosity.
A split selection reads as Mixed in placeholder grey rather than as one member's value. Colour drops its swatch entirely, since the empty checkerboard already means None here — a value an annotation can 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. The engine writes strokeColor ?? color, so a second row's edits would land nowhere.
Print is PDF's own Print flag (/F bit 3): turn it off and the mark stays on screen but leaves the paper — the standard way to keep review markup out of a printed copy. Every tool sets it, 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 clobber 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 popover holds three ways to reach a colour: the plugin's presets, a saturation-and-brightness surface with a hue rail under it, and a hex field for a colour you were handed rather than chose. Picking a preset doesn't close the popover — it's usually the nearest colour to start from, not the answer.
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 stays put. Enter or blur commits, Esc reverts. Anything that isn't a colour reverts too rather than clearing the field — clearing has a control of its own. On a split selection it reads Mixed, like every other field.
The picker commits on release, so a drag is one engine write and one undo entry. Arrow keys move it too and commit per press; Shift takes the coarse step, and 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. The
popover answers for the page in the meantime — the trigger's chip, its value,
and the hex field all track the colour under the pointer. A transaction or
silent write upstream would remove the trade.
No colour#
None leads the palette on the fill row, drawn as the checkerboard the panel uses 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 way of saying there is none — no /IC means the shape isn't filled, no /C means it isn't stroked. Every shape tool ships that way, which is why an unfilled rectangle is what you get by default.
It's on the fill row only. A stroke is the one thing keeping an unfilled shape visible, and the engine's write path for a stroke colour expects a hex, so a None there would throw rather than clear.
Zero is likewise a stroke width the spec defines rather than a floor to clamp: /BS /W 0 means no border shall be drawn. A slider starting at 1 would quietly give 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.
Dashes and clouds#
/BS carries more than a width. Its /S names the border style, and Style offers two of the six that entry can hold. /B beveled and /I inset are 3-D edges the plugin's renderers don't draw, so picking one would change the file and not the page; /U underline belongs to links, which this panel doesn't draw. That leaves solid and dashed.
Choosing Dashed writes a dash pattern along with the style, since /D is a separate entry the file may never have held and the overlay hands it straight to SVG's stroke-dasharray. Dash sets the length of that pattern's run, in points, and appears only while the style is dashed.
Cloud is the border effect — /BE /I (ISO 32000-1 §12.5.4, table 167) — the scalloped edge used to ring a region on a drawing. The spec defines an intensity of 0, 1, or 2 and nothing between, so it's a three-stop slider, and returning it to 0 clears /BE rather than leaving an effect dictionary behind at zero strength. It's not a seventh border style: cloudiness is its own entry, the engine rewrites a Cloudy style back to Solid on save, and a cloud may itself be dashed.
Style and dash are for the shapes that carry /BS — square, circle, line, polyline, and polygon. Cloudiness is narrower: /BE applies to the shapes that enclose an area, so Cloud shows on square, circle, and polygon only.
Line endings#
A line and a polyline carry /LE, a pair naming what to draw where the stroke stops: an open or closed arrow either way round, a square, a circle, a diamond, a butt cap, a slash. Start and End set the two halves, and each writes the pair back whole, so putting a diamond on the start keeps the arrow tool's open arrow on the end.
The enum carries an eleventh member, Unknown, for an ending the engine couldn't place. It isn't a PDF name and nothing draws it, so the rows show it as no selection and never offer it.
A free text callout carries a single /LE, for the end of its leader line, and gets one Arrow row rather than two. That row is gated on the annotation actually having a leader: a plain free text box has no line to put an ending on.
The arrow tool is in the default toolbar, so Start and End are one
drawn line away. line, polygon, polyline, and freeTextCallout aren't —
name them in the toolbar's tools prop to reach a polygon's cloud or a
callout's arrow.
Blend modes#
Blend is how the annotation's appearance composites with the page beneath it — the /BM of the graphics state the appearance stream is drawn under (ISO 32000-1 §11.3.5). Normal paints over. Multiply darkens, which is what makes a highlighter look like ink on paper rather than paint over words, and it's what the highlighter tool ships with.
The row lists the spec's own sixteen in the spec's own order: the twelve separable modes of table 136, then the four non-separable ones of table 137. They read as image-editor vocabulary because PDF's blend functions and Photoshop's are the same functions.
It's a property of the graphics state rather than of any one subtype, so the row appears wherever there's an appearance to composite — every annotation the panel shows an opacity for.
Typography#
Free text carries type of its own, and the Text section sets it: the face, the size, the colour, and where the words sit in the box.
The families are the Standard 14 — the fourteen faces every conforming reader has without embedding anything — so the list is five entries with Bold and Italic beside it as toggles. PDF doesn't model it that way: /DA names one face, Helvetica-BoldOblique, so a single value in the file becomes a family and two switches here. Symbol and ZapfDingbats are glyph sets with no bold or italic cut, so their toggles go disabled with a line saying why — asking for bold Symbol resolves to Helvetica and loses the family.
Size runs 4 to 96 points. Neither end is a limit of the format, just the useful range: four points still reads in print, and 96 is a full-width heading on A4.
Align is PDF's own /Q (ISO 32000-1 §12.5.6.6, table 174) — quadding: left, centre, right. Vertical has no entry in the standard; a free text annotation's appearance stream is expected to have already decided where the text sits down the box. embedpdf keeps that editable under /EPDF:VerticalAlignment, outside the standard's namespace, and bakes the resulting layout into the stream it writes. The text lands where you put it in every reader; only embedpdf can move it again, and another editor re-saving the annotation drops the key while keeping whatever the stream said.
A split selection presses nothing on either row rather than reading Mixed — there's no room for the word beside three buttons.
Plain free text has no Stroke rows, even though the plugin accepts a stroke width and colour on one: the engine only reads them back when the saved border width is above zero, so a row offered at zero would show on the box you just drew and be gone the next time the document opened. A callout carries both from its tool defaults and gets the rows through the ordinary path; its Arrow row is gated on the leader line rather than the border, so it survives the round trip either way. Neither gets a Style row — the engine writes a free text border as solid whatever it's handed.
Kerning, tracking, and word spacing aren't properties of an annotation: Tc,
Tw, and Tz are content-stream operators, and the annotation dictionary has
nowhere to keep them. Rich text — /DS and /RC — is in the format and in
the plugin's read path, but the engine never writes either, so no control here
would survive a save. Both wait on upstream.
Black text on a red palette#
embedpdf arms every tool with #E44234, the first swatch of the palette it inherits from Apryse's WebViewer. That's right for a markup mark, which is meant to be impossible to miss. Text isn't markup: a free text annotation is words placed on the page — a label, a caption, a filled-in blank — and PDF paints those black by omission, since a /DA with no colour operator resolves to black (ISO 32000-1 §12.7.4.3). Acrobat splits the two the same way, and so does embedpdf v3.
@pdfviewcn/annotation-tools ships that split as a tool override:
pnpm dlx shadcn@latest add @pdfviewcn/annotation-tools
import { createPluginRegistration } from "@embedpdf/core";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
import { pdfAnnotationToolOverrides } from "@/registry/lib/pdf-annotation-tools";
const plugins = [
createPluginRegistration(AnnotationPluginPackage, {
tools: pdfAnnotationToolOverrides,
}),
];The plugin deep-merges an override whose id matches a tool it already has, so only fontColor changes and everything else — the tool's pointer handler, its click-to-place behaviour, its transform — stays exactly as shipped. The callout keeps its red leader line, since the arrow is a markup mark and it's pointing at something; only the words in its box turn black.
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 is a markup type whose color only mirrors its stroke, since a row meaning the interior for half the selection and the stroke for the rest can't be labelled honestly. 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, visibly redrawing a stamp, a signature, or anything imported.
The trade is undo granularity: it registers one history command per call, so
aligning four annotations leaves up to four undo steps. Annotations already on
the target edge are skipped.
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 pdfviewcn. 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 repeat across sections — three own a Color, two a Width — so each section is a group labelled by its own heading, and a screen reader announces "Stroke, group" on the way in rather than reading a hidden qualifier on every row.
A colour trigger's 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". A dropdown takes the row's label alone — it reports as a combobox, which exposes its selection as a value already ("Blend, Normal"), so composing the two would say it twice.
Colour presets are buttons with aria-pressed and are labelled with their colour value, so the selected swatch isn't conveyed by the ring alone. The controls beneath them are named for what they are — "Color, saturation and brightness", "Color, hue", "Color, hex" — so the popover 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 into aria-valuetext, so the arrow keys that move it vertically announce what they did. The hex field is the way in for anyone who'd rather state a colour than steer to one.
The alignment rows are toggle groups of icons: the group carries the property name and each item its value — "Align" then "Left". Where the font style toggles are disabled for a single-face family, the group points aria-describedby at the row's info affordance, so the reason reaches a screen reader rather than only a hover.
Numeric fields commit on blur or Enter and discard on Escape, so a half-typed value never moves the annotation. They're real number inputs, keeping the spinbutton role, the announced range, and arrow-key stepping — only the native spinner is hidden, with appearance. They carry step="any" because PDF user-space coordinates are fractional: the default step="1" would mark a height of 123.6 invalid. 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 opens for the keyboard. The same prose is rendered offscreen and pointed at by the switch's aria-describedby, since Base UI's tooltip deliberately doesn't describe its trigger.
Align and distribute are icon-only and carry their name as aria-label as well as in a tooltip — a disabled button swallows hover and takes the tooltip with it.
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.
On This Page
AboutInstallationUsageStructureExamplesDefaultEditing a tool before you drawNaming a colourNo colourDashes and cloudsLine endingsBlend modesTypographyBlack text on a red paletteMulti-selectionArranging a multi-selectionGroupingWhen annotating is disabledAccessibilityAPI ReferencePdfAnnotationInspectorusePdfAnnotationSelector