Edit the field behind a selected form widget — its name, value, options, and flags.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
PDF keeps an interactive form in two halves, and they aren't the same object.
The widget is an annotation subtype (ISO 32000-1 §12.5.6.19): a rectangle on a page with a border, a background, and a font. That's what <PdfAnnotationInspector /> edits.
The field (§12.7) is what the widget is a view of — the name it submits under, whether it's required, the list a dropdown offers, the value it holds. <PdfFormFieldInspector /> edits that half.
They're separate because one field can own several widgets. A radio group is one field drawn in three places; filling in any of them fills the field.
The inspector follows the annotation plugin's selection and shows a field only when the selection is exactly one widget. Names are unique and options are per field, so there's no "apply to all" here the way there is for a colour.
Editing a field means the document is in design mode, where widgets are
selectable annotations. Under the form fill
toggle the form category is locked,
clicks fall through to the input, and nothing gets selected for this panel to
show.
Installation#
pnpm dlx shadcn@latest add @pdfviewcn/form-field-inspector
Usage#
Register the annotation and form plugins.
import { createPluginRegistration } from "@embedpdf/core";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
import { FormPluginPackage } from "@embedpdf/plugin-form/react";
// Outside the component — a new array identity on every render tears the
// engine down and rebuilds it, losing scroll and zoom state. The annotation
// plugin is left unlocked, so the document opens in design mode and its
// widgets are selectable.
const plugins = [
createPluginRegistration(AnnotationPluginPackage),
createPluginRegistration(FormPluginPackage),
];Render the annotation layer — form widgets render through it, not through a layer of the form plugin's own — and put the inspector beside the page.
<PdfViewer
documents={[{ url: "/form.pdf" }]}
plugins={plugins}
className="h-[720px]"
>
<PdfToolbar>
<PdfAnnotationToolbar tools={[TOOL_GROUPS.form]} />
</PdfToolbar>
<div className="flex min-h-0 flex-1">
<PdfViewerContent
className="flex-1"
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
)}
/>
<PdfFormFieldInspector className="w-72 border-l" />
</div>
</PdfViewer>The toolbar is optional — the inspector edits fields a document already has just as well as ones you place. TOOL_GROUPS.form is the five field tools; see the annotation toolbar for why they're opt-in.
Structure#
Which sections appear depends on /FT, the field's type, because most of them only mean something for some types.
| Section | Shown for | Edits |
|---|---|---|
| Identity | every field | /T (name), plus read-only /FT and how many widgets share the field |
| Value | text fields, checkboxes, radio buttons | /V |
| Options | combo boxes, list boxes | /Opt — the list of items, and which of them are selected |
| Behaviour | every field | /Ff — the flag bits that apply to that type |
Identity#
/T is a text input. Renaming is the one edit that can't just be written, because §12.7.3.2 says widgets sharing a fully qualified name are one field — that's the mechanism a radio group is built from, and also the way to merge two unrelated fields by accident.
So renaming onto a name that's already taken comes back as a conflict, and the panel puts the question to you:
Name already in use — Another field is already called
Total. Joining them makes one field drawn in two places — they share a value from then on, which is how a radio group is built. Cancel / Join
Joining is a second, explicit call. When the answer is ambiguous instead — several fields hold the name, or the one that does is a different field type — the rename is rejected outright and the plugin's own message is shown.
/FT is read-only. Changing it would change what the widget's appearance stream draws and what its value means, which is a new field rather than an edit.
The widget count comes from the plugin's own field index, keyed on the field's object number. Freshly placed fields get a unique name — TextField_4ad1bf92 — so two placed in a row never merge on their own.
Value#
A text field's /V is a text input, committed on blur or Enter and reverted on Escape — a round trip per keystroke would regenerate the widget's appearance stream each time.
A checkbox or radio button's /V is a name, not a boolean: it's the on-state key from the widget's /AP dictionary when on, and /Off when not (§12.7.4.2). The switch reads field.value === exportValue and writes the key back, and its hint names the key — On writes /V as "Yes"; off writes /Off. A widget with no on-state yet has nothing to write, so the switch disables and says so rather than flipping and snapping back.
Choice fields have no Value section: for them /V is the chosen /Opt entry, so it lives on the option rows instead.
Options#
Each row is a selected toggle, an editable label, and a remove button. Selection is exclusive unless the list box carries the multi-select flag.
A row's two halves take different routes: a label is structure and goes through the annotation plugin, a selection is a value and goes through the form plugin's fill layer — the same call the rendered dropdown makes, so picking an option here does what picking it on the page does.
Pressing the selected row of a single-select list doesn't clear it. That's what a radio does, and the fill layer has no "select nothing" for a combo box either.
Behaviour#
/Ff bits, as switches, filtered to the ones that apply:
| Field type | Flags |
|---|---|
| every type | Read-only (bit 1), Required (bit 2) |
| Text field | Multiline (bit 13), Password (bit 14) |
| Combo box | Editable (bit 19), Sort options (bit 20) |
| List box | Multi-select (bit 22), Sort options (bit 20) |
| Checkbox, Radio | none beyond the common two |
The filtering matters: the spec reuses bit numbers across field types — bit 26 is RichText on a text field and RadiosInUnison on a button — so one merged list would mislabel half of them.
Radio buttons show nothing extra because the engine force-sets their Radio and NoToggleToOff bits every time the widget is written; a switch for either would flip back on the next save.
NoExport (bit 3) is left out. It's real and it's universal, but nothing in pdfviewcn submits a form, so a switch for it would promise an action that doesn't exist here.
Examples#
Default#
Fields render through the annotation layer in design mode. Click one to edit it; place new ones with the toolbar's form tools.
"use client";
import { createPluginRegistration } from "@embedpdf/core";Beside the annotation inspector#
The two panels edit the two halves of the same widget, so they belong side by side rather than nested. In a tabbed rail, pass heading={null} — the tab strip already names the panel:
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="properties">Properties</TabsTrigger>
<TabsTrigger value="field">Field</TabsTrigger>
</TabsList>
<TabsContent value="properties" className="min-h-0">
<PdfAnnotationInspector className="h-full" />
</TabsContent>
<TabsContent value="field" className="min-h-0">
<PdfFormFieldInspector heading={null} className="h-full" />
</TabsContent>
</Tabs>The field's type stays visible either way — it's a row in the first section, not header trim.
When editing or filling is denied#
The panel splits its permissions the way §7.6.3.2 does, because the two aren't the same grant:
- Name, Options, Behaviour need modify-annotations (
/Pbit 6) — authoring. - Value needs fill-in-form-fields (
/Pbit 9) — filling.
A document can be fillable but not editable, which is the common case for a form somebody sends you. The panel disables per section rather than shutting itself off, and adds a line at the foot when authoring is denied. See Permissions for the full model.
Accessibility#
Every row is a labelled control: the label element is associated by id rather than by wrapping, so the fixed label column keeps its own layout. Rows whose meaning comes from a PDF entry carry an info button naming that entry, and the same text is attached through aria-describedby, so it's announced rather than only hoverable.
Option rows announce their own label: Select Option 2, Remove Option 2. The selected toggle exposes its state through aria-pressed, so a single-select list reads as a choice rather than as three unrelated buttons.
Selecting a field requires a pointer, since a widget is selected by clicking it on the page — everything in the panel is keyboard-reachable once one is.
API Reference#
PdfFormFieldInspector#
A <div> carrying data-slot="pdf-form-field-inspector". Accepts every <div> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | The ambient document | Which document to read the selection from. |
heading | React.ReactNode | null | "Field" | The panel's title. null drops the header when surrounding chrome already names it. |
Returns null when the document isn't loaded, or when either the annotation or the form plugin isn't registered. Renders an empty state when the selection isn't exactly one widget.
Sections carry data-slot="pdf-form-field-inspector-section"; the header carries data-slot="pdf-form-field-inspector-header".
Writes are routed by which half of the form they touch. /T goes through the form plugin's renameField and shareField, which is what carries the conflict detection. /V and option selection go through setFormFieldValues, the fill layer. /Ff and option labels go through the annotation plugin's updateAnnotation — the only path that reaches them, and the same one a drag or a resize takes.
What isn't here#
Three field entries have no row, in each case because the engine underneath has no way to write one. They're named rather than quietly dropped:
| Entry | Why |
|---|---|
/TU (tooltip) | Read-only — the engine reads the alternate name but exposes no setter for it. |
/MaxLen | Writable, but not clearable: the engine skips the write when the length is zero. |
/DV (default) | Not exposed at all, so there'd be nothing for "reset the form" to reset to. |