- 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 Layer
- 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, 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.
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 — 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.
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.
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.
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 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.
Kerning, tracking, and word spacing aren't here because they aren't properties
of an annotation: Tc, Tw, and Tz are content-stream operators the
appearance generator emits, and the annotation dictionary has nowhere to keep
them. Rich text — /DS and /RC, per-run styling inside one box — is in the
format and in the plugin's read path, but the engine never writes either, so
there's no control here that 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. 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.
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 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 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.
| 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