Building forms
The form plugin can also add fields to a PDF, and change or remove the ones it has, so people can turn any document into a form. Switch to design mode, pick a field tool, and click or drag on the page:
import { useInteraction, useInteractionState } from '@embedpdf/react/interaction';
const FIELD_TOOLS = [
['form-text', 'Text'],
['form-checkbox', 'Checkbox'],
['form-radio', 'Radio button'],
['form-combobox', 'Dropdown'],
['form-listbox', 'List'],
['form-signature', 'Signature'],
] as const;
export function FieldPalette() {
const interaction = useInteraction();
const { activeToolId } = useInteractionState();
return FIELD_TOOLS.map(([id, label]) => (
<button key={id} aria-pressed={activeToolId === id} onClick={() => interaction.activateTool(id)}>
{label}
</button>
));
}Building needs the annotation plugin next to the form plugin: in design mode, fields are boxes you select, move, resize and style like any annotation.
| Tool | Places | A click makes |
|---|---|---|
form-text | A text box | 160 × 24 |
form-checkbox | A checkbox | 18 × 18 |
form-radio | A radio button | 18 × 18 |
form-combobox | A dropdown | 140 × 24 |
form-listbox | A list | 140 × 72 |
form-button | A push button | 100 × 28 |
form-signature | A field someone signs later | 160 × 48 |
A see-through preview of the field follows the pointer, drawn as the field will be, so people see where it lands before they click. Drag instead of clicking for another size. The tool stays active, so placing ten checkboxes is ten clicks.
Loading live preview…
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin, useInteraction, useInteractionState } from '@embedpdf/react/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/react/annotation';
import { FormLayer, formPlugin, useForm, useFormState } from '@embedpdf/react/form';
import { localEngine } from '@embedpdf/engine';
import './designer.css';
const engine = localEngine();
// Building forms needs the annotation plugin: in design mode, fields are boxes like any annotation.
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
annotationPlugin(),
formPlugin(),
];
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
const FIELD_TOOLS = [
['form-text', 'Text'],
['form-checkbox', 'Checkbox'],
['form-radio', 'Radio button'],
['form-combobox', 'Dropdown'],
['form-listbox', 'List'],
['form-signature', 'Signature'],
] as const;
/** Start on the ebook's last page, which has room for a form, with the text tool picked. */
function useStartDesigning() {
const interaction = useInteraction();
const stage = useStage();
const page = usePageList().at(-1)?.ref;
const started = useRef(false);
useEffect(() => {
if (!page || started.current) return;
started.current = true;
stage.goToPage(page);
interaction.activateTool('form-text');
}, [interaction, stage, page]);
}
function FieldPalette() {
useStartDesigning();
const interaction = useInteraction();
const form = useForm();
const { activeToolId } = useInteractionState();
const { fields } = useFormState();
// The pointer fills the form in; every other tool here designs it.
const filling = activeToolId === 'pointer';
if (!form.canDesign()) return <p className="readout">This document’s form can’t be changed.</p>;
return (
<div className="toolbar">
<div className="segments">
<button
type="button"
className="segment"
aria-pressed={filling}
onClick={() => interaction.activateTool('pointer')}
>
Fill in
</button>
<button
type="button"
className="segment"
aria-pressed={!filling}
onClick={() => interaction.activateTool('form-edit')}
>
Design
</button>
</div>
{FIELD_TOOLS.map(([id, label]) => (
<button
key={id}
type="button"
className="button"
aria-pressed={activeToolId === id}
onClick={() => interaction.activateTool(id)}
>
{label}
</button>
))}
<output className="readout">
{fields.length === 0 ? 'Click the page to place a field' : `${fields.length} fields`}
</output>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<FieldPalette />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<AnnotationLayer />
<FormLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}Design mode#
The form-edit tool is design mode: fields stop being fillable and become boxes you can select, move
and resize. The pointer tool makes them fillable again:
interaction.activateTool('form-edit'); // design the form
interaction.activateTool('pointer'); // fill it inIn design mode the form lends its boxes to the annotation plugin, which selects, moves, resizes,
restyles, reorders and deletes them with its own tools, next to the annotations. Every edit of a box
is still the form’s: its place or look is updateWidget(), and deleting every box of a field
deletes the field. A drag of two fields and a stamp is one change: one request, and one step to
undo. Like every change, it shows at once and the engine’s answer follows.
A field just placed is selected at once, before the engine answers, and the tool goes back to
form-edit rather than the default pointer, so design mode goes on. Without canDesign() nothing is
lent: the fields stay fillable boxes the annotation tools leave alone.
Change a field’s settings#
useFormState().selectedField is the field of the selected box, for a settings panel:
import { useForm, useFormState } from '@embedpdf/react/form';
export function FieldSettings() {
const form = useForm();
const { selectedField: field } = useFormState();
if (!field) return null;
return (
<>
<input defaultValue={field.name} onBlur={(e) => form.update(field.ref, { name: e.target.value })} />
<label>
<input
type="checkbox"
checked={field.required}
onChange={(e) => form.update(field.ref, { required: e.target.checked })}
/>
Required
</label>
</>
);
}update() takes only what you change, with the same settings a field reads:
| Setting | What it is |
|---|---|
name | Its own part of its name: renaming customer.email to phone makes customer.phone |
required | PDF apps ask for a value before the form is submitted |
readOnly | People can’t change the value |
alternateName | The tooltip, also read by screen readers |
defaultValue | What a reset puts back |
options | For a dropdown or list: { label, value } pairs |
maxLength, multiline, password, comb | For a text box |
getFieldSettings(ref) describes a field’s settings for a panel you draw without knowing the
field’s family: { properties, values }, each property a key (what update() takes), a control
(text, flag, number, choice, choices or options), and a label in English with its
translation key, labelKey (form.settings.tooltip), in display order. A choice‘s options are
the field’s own values, shown as they are.
A field’s look, such as its border, background and font, belongs to its box: change it like any
annotation’s, with the style panel, or from code with
form.updateWidget(widget.ref, { color: '#1d4ed8' }).
Loading live preview…
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin, useInteraction } from '@embedpdf/react/interaction';
import { AnnotationLayer, annotationPlugin, useAnnotation } from '@embedpdf/react/annotation';
import { FormLayer, formPlugin, useForm, useFormState } from '@embedpdf/react/form';
import { localEngine } from '@embedpdf/engine';
import './field-settings.css';
const engine = localEngine();
const plugins = [
stagePlugin(),
renderPlugin(),
interactionPlugin(),
annotationPlugin(),
formPlugin(),
];
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// How the new fields look: part of the PDF, like the rest of the page.
const look = { color: '#94a3b8', interiorColor: '#f8fafc', strokeWidth: 1, fontSize: 11 };
/** The ebook has no form: add two fields to its last page, and select the first in design mode. */
function useDesignForm() {
const form = useForm();
const annotation = useAnnotation();
const interaction = useInteraction();
const stage = useStage();
const status = useFormState((state) => state.status);
const page = usePageList().at(-1)?.ref;
const added = useRef(false);
useEffect(() => {
if (status !== 'ready' || !page || added.current) return;
added.current = true;
const at = (y: number) => ({ page, rect: { x: 72, y, width: 240, height: 24 }, ...look });
void (async () => {
const { field } = await form.create({ family: 'text', name: 'name', widgets: [at(540)] });
await form.create({ family: 'text', name: 'email', widgets: [at(576)] });
stage.goToPage(page);
interaction.activateTool('form-edit');
const widget = field.widgets[0]?.ref;
if (widget) annotation.selection.set([widget]);
})();
}, [form, annotation, interaction, stage, status, page]);
}
/** What a settings panel shows for the selected field, and changes with `update()`. */
function FieldSettings() {
useDesignForm();
const form = useForm();
const { selectedField: field } = useFormState();
if (!field) return <p className="panel hint">Select a field on the page.</p>;
return (
<div className="panel">
<label className="setting">
Name
<input
key={field.name}
className="input"
defaultValue={field.name}
onBlur={(event) => {
const name = event.target.value.trim();
if (name && name !== field.name) void form.update(field.ref, { name });
}}
/>
</label>
<label className="setting">
Tooltip
<input
key={`${field.name}:tooltip`}
className="input"
defaultValue={field.alternateName ?? ''}
onBlur={(event) =>
void form.update(field.ref, { alternateName: event.target.value || null })
}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={field.required}
onChange={(event) => void form.update(field.ref, { required: event.target.checked })}
/>
Required
</label>
<label className="check">
<input
type="checkbox"
checked={field.readOnly}
onChange={(event) => void form.update(field.ref, { readOnly: event.target.checked })}
/>
Read-only
</label>
<button type="button" className="button" onClick={() => void form.delete(field.ref)}>
Remove the field
</button>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<div className="viewer">
<Stage className="stage">
{() => (
<>
<RenderLayer />
<AnnotationLayer />
<FormLayer />
</>
)}
</Stage>
<FieldSettings />
</div>
</DocumentGate>
</Viewer>
);
}Add fields from code#
create() takes the same settings the engine takes, with the page and the box for each place the
field shows:
const { field } = await form.create({
family: 'combobox',
name: 'country',
options: [
{ label: 'Netherlands', value: 'NL' },
{ label: 'Belgium', value: 'BE' },
],
widgets: [{ page: page.ref, rect: { x: 72, y: 252, width: 148, height: 20 } }],
});A radio group is one field with a box per button, each with the value it stands for:
await form.create({
family: 'radio',
name: 'plan',
widgets: [
{ page: page.ref, rect: { x: 72, y: 136, width: 16, height: 16 }, exportValue: 'monthly' },
{ page: page.ref, rect: { x: 72, y: 166, width: 16, height: 16 }, exportValue: 'yearly' },
],
});A field placed with a tool gets a name like text_1; rename it in your settings panel. Every
family’s settings are on Creating form fields.
Loading live preview…
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import { FormLayer, formPlugin, useForm, useFormState } from '@embedpdf/react/form';
import type { FormCapability } from '@embedpdf/react/form';
import type { PageRef } from '@embedpdf/react/runtime';
import { localEngine } from '@embedpdf/engine';
import './create.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), formPlugin()];
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// How the new fields look: part of the PDF, like the rest of the page.
const look = { color: '#94a3b8', interiorColor: '#f8fafc', strokeWidth: 1, fontSize: 11 };
/** A dropdown, the next row down the page. */
function addDropdown(form: FormCapability, page: PageRef, row: number) {
return form.create({
family: 'combobox',
name: `country_${row + 1}`,
options: [
{ label: 'Netherlands', value: 'NL' },
{ label: 'Belgium', value: 'BE' },
],
widgets: [{ page, rect: { x: 72, y: 520 + row * 36, width: 160, height: 24 }, ...look }],
});
}
/** A radio group: one field with a widget per button, each with the value it stands for. */
function addRadioGroup(form: FormCapability, page: PageRef, row: number) {
const y = 524 + row * 36;
return form.create({
family: 'radio',
name: `plan_${row + 1}`,
widgets: [
{ page, rect: { x: 72, y, width: 16, height: 16 }, exportValue: 'monthly', ...look },
{ page, rect: { x: 112, y, width: 16, height: 16 }, exportValue: 'yearly', ...look },
],
});
}
function BuildToolbar() {
const form = useForm();
const stage = useStage();
const { status, fields } = useFormState();
const page = usePageList().at(-1)?.ref;
const added = useRef(false);
// The ebook has no form: start with a dropdown on its last page, which has room.
useEffect(() => {
if (status !== 'ready' || !page || added.current) return;
added.current = true;
void addDropdown(form, page, 0).then(({ field }) =>
stage.reveal(page, { rect: form.getWidget(field.widgets[0])!.rect }),
);
}, [form, stage, status, page]);
// Each new field goes a row further down the page: bring it into view.
const add = (addField: typeof addDropdown) => {
if (!page) return;
void addField(form, page, fields.length).then(({ field }) =>
stage.reveal(page, { rect: form.getWidget(field.widgets[0])!.rect }),
);
};
const last = fields.at(-1);
const full = fields.length >= 5;
return (
<div className="toolbar">
<button
type="button"
className="button"
disabled={!page || full}
onClick={() => add(addDropdown)}
>
Add a dropdown
</button>
<button
type="button"
className="button"
disabled={!page || full}
onClick={() => add(addRadioGroup)}
>
Add a radio group
</button>
<button
type="button"
className="button"
disabled={!last}
onClick={() => last && void form.delete(last.ref)}
>
Remove the last
</button>
<output className="readout">{fields.map((field) => field.name).join(', ') || 'No fields'}</output>
</div>
);
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<BuildToolbar />
<Stage className="stage">
{() => (
<>
<RenderLayer />
<FormLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}One more button for a group#
addWidget(ref) shows a field in one more place: for a radio group a new choice, for a checkbox a
box that checks together with the others.
const { widget } = await form.addWidget(plan.ref);
annotation.selection.set([widget.ref]); // in design mode, so it can be draggedWithout a place, it goes next in line: below the last button if they stand in a column, beside it
if in a row, at the same size and the same spacing, starting a new column where the page ends. It
looks like the field’s last button, and a radio group’s new choice gets the next free Choice<n>.
Pass what you want instead: form.addWidget(plan.ref, { rect, exportValue: 'yearly' }) takes
everything an entry of create()‘s widgets takes.
Scripts and calculations#
A field’s scripts go in actions when you create or change it: a calculated total, a formatted
amount, a check on what the user typed. The viewer runs them while the user fills the form in.
await form.update(total.ref, {
actions: {
calculate: {
type: 'javascript',
script: 'event.value = this.getField("subtotal").value * 1.21;',
},
},
});
await form.reorderCalculations([tax.ref], { before: total.ref });A calculate script puts the field at the end of the order calculations run in;
reorderCalculations() moves it. Writing a script needs canWriteScripts(). See
Scripts and calculations.
A box can do something when it’s clicked, too, such as reset the form: give its entry of
widgets in create() its actions. A script, a submit or a web link there needs
canWriteScripts() as well. See Widget actions.
A push button (family: 'pushbutton') is a box that only does that, with a caption; see
A push button.
Remove a field#
await form.delete(field.ref);This removes the field and every box it has. To remove one box of a field that shows in several places,
such as a reference number on every page, use form.deleteWidget(widget.ref). It resolves the field
as it is now. In design mode, deleting the selection does the same: one box goes from its field, and
a field whose every box is selected goes whole.
Every building method shows its change at once, is one step to undo, and resolves once the engine answered.
Methods#
What useForm() gives you for building forms. Reading, filling and the form’s state are on
Filling forms.
| Method | What it does |
|---|---|
create(draft) | Adds a field, with a box for each place it shows. Resolves { field }. Add fields from code |
update(ref, patch) | Changes a field’s settings; pass only what changes. Resolves { field }. Change a field’s settings |
addWidget(ref, placement?) | Shows a field in one more place: a radio group’s next choice, a checkbox’s next box. Resolves { field, widget }. One more button for a group |
delete(ref) | Removes a field and every box it has. Remove a field |
updateWidget(widget, patch) | Changes one box’s place or look; pass only what changes. Resolves { widget }. Change a field’s settings |
getFieldSettings(ref) | A field’s settings described for a panel: { properties, values }, or null. Change a field’s settings |
deleteWidget(widget) | Removes one box from its page, and from its field when it has one. Resolves { field }, or { field: null } for a box in no field. Remove a field |
removeWidget(ref, widget) | Takes one box out of its field; it stays on the page, in no field, until deleteWidget() removes it |
reorderCalculations(fields, position) | Moves fields in the order calculate scripts run in. Resolves { calculationOrder }. Scripts and calculations |
repair(options?) | Fixes a form other PDF apps read differently, such as fields missing from the form’s list, and resolves what it fixed. See Repair a broken form. |
canDesign() | Whether adding, changing and removing fields is allowed. Permissions |
canRestoreAttribution() | Whether an import may keep who created and filled in its fields, instead of naming the importing user. Permissions |
canWriteScripts() | Whether scripts, submit buttons and links may be written into fields and their boxes. Permissions |
Every method that returns a promise also takes a signal in its options, to cancel it.
Events#
import { useFormEvent } from '@embedpdf/react/form';
import { selectField } from './editor';
export function SelectNewFields() {
useFormEvent(
(form) => form.onFieldCreated,
({ field }) => selectField(field),
);
return null;
}| Event | Fires when | Carries |
|---|---|---|
onFieldCreated | A field was added | field, origin |
onFieldUpdated | A field’s settings changed | field, origin |
onFieldDeleted | A field was removed | ref, origin |
Permissions#
| Permission | What it allows | Check |
|---|---|---|
doc.forms.modify | Adding, changing, removing and repairing fields | form.canDesign() |
doc.forms.import | Importing fields and values with who created and filled them in | form.canRestoreAttribution() |
doc.forms.script | Writing scripts, submit buttons and links into fields and boxes | form.canWriteScripts() |
Hide the field tools when canDesign() is false, and the script, submit and link options of a
field’s or a box’s actions when canWriteScripts() is false. Scripts already in the form run either way.
Good to know#
Fields and boxes#
A field is the data: its name, its value, its settings. A widget is a box where the field shows on a page. Most fields have one; a radio group has one per button, and a field can show on several pages, all with the same value.
Signature fields#
A signature field is a place for someone to sign later. Signing it is on Signatures.
Your feedback goes directly to the documentation team.