0

PDF Annotation Inspector

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 /> 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.

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 — 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 property with a handful of pictorial values is a row of segmented icons instead: three glyphs say left, centre, right without being opened first, and one click changes the answer. A closed set too long to press — sixteen blend modes, ten line endings — is a dropdown naming its values, since there is no glyph anyone reads as Luminosity.

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 pdfviewcn 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.

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.

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 the raised and sunken 3-D edges of an era of software that drew buttons, and the plugin's renderers don't draw either — 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: the two every border dialog anywhere offers, and the two that render.

Choosing Dashed writes a dash pattern along with the style. /D is a separate entry the file may never have held, and the overlay hands it straight to SVG's stroke-dasharray, where nothing draws a solid line — a style sent on its own would be a control that does nothing. Dash then sets the length of that pattern's run, in points, and appears only while the style is dashed, since it has nothing to say otherwise.

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 rather than a free number, and returning it to 0 clears /BE rather than leaving an effect dictionary behind at zero strength. It's deliberately not a seventh border style: cloudiness is a separate entry in the file, the engine rewrites a Cloudy style back to Solid on save, and a cloud may itself be dashed. One PDF entry, one row.

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 setting one never silently resets the other — which is what keeps the arrow tool's open arrow on the end when you put a diamond on the start.

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 it's a value these rows may be asked to show — as no selection — and never one they offer.

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.

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 that's what they are — 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 is where it's set: 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 and Bold and Italic sit beside it as toggles. PDF doesn't model it that way: /DA names one face, Helvetica-BoldOblique, so what is a single value in the file is a family and two switches in the panel, which is how anyone who has used a text tool expects to choose a font. Symbol and ZapfDingbats are glyph sets rather than typefaces and the standard fourteen has no bold or italic cut of either, so their toggles go disabled with a line saying why — asking for bold Symbol resolves to Helvetica and silently loses the family.

Size runs 4 to 96 points. Neither end is a limit of the format — a PDF scales the outlines, so there's no size a Standard 14 face can't be — they're the range that's useful: four points is the floor at which a stamped line 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 at all. /Q is horizontal only, and a free text annotation's appearance stream is simply expected to have already decided where the text sits down the box. embedpdf keeps that decision editable by storing it under /EPDF:VerticalAlignment, a key of its own 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 already said.

A split selection presses nothing on either row rather than reading Mixed — there's no room for the word beside three buttons, and pressing a segment resolves the split anyway.

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 control that forgets. 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 on 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, so a dashed one isn't a thing it can save.

Black text on a red palette

embedpdf arms every tool with #E44234, the first swatch of the palette it inherits from Apryse's WebViewer. For a markup mark that's the right default: a review mark is meant to sit on top of the page and 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: with no colour operator in /DA, a conforming reader uses black (ISO 32000-1 §12.7.4.3). Acrobat splits the two the same way, keeping red for the typewriter and replacement tools, and embedpdf v3 splits them too.

@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 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.

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.

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.

A dropdown takes the row's label alone, which looks like the opposite rule and is the same one. It reports as a combobox, and a combobox exposes its selection as a value rather than as part of its name — "Blend, Normal" — so composing the two would say the value twice. The colour trigger is a plain button with no value of its own to expose, which is why that one is composed.

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.

The alignment rows are toggle groups of icons, so the group carries the name of the property and each item the name of its value: "Align" then "Left", rather than "Align left" three times over with the row's label read again in front of it. Where the font style toggles are disabled for a single-face family, the row's info affordance carries the reason and the group points aria-describedby at it, so "the Standard 14 has no bold or italic cut of either" reaches a screen reader rather than only a hover.

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.

PropTypeDefaultDescription
documentIdstring | nullThe ambient documentWhich 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,
);
ArgumentTypeDescription
documentIdstringThe document whose annotation state to read.
selector(state: AnnotationDocumentState) => TThe slice to subscribe to.
isEqual(a: T, b: T) => booleanDefaults 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.