EmbedPDF

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:

field-palette.tsx
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.

ToolPlacesA click makes
form-textA text box160 × 24
form-checkboxA checkbox18 × 18
form-radioA radio button18 × 18
form-comboboxA dropdown140 × 24
form-listboxA list140 × 72
form-buttonA push button100 × 28
form-signatureA field someone signs later160 × 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 in

In 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:

field-settings.tsx
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:

SettingWhat it is
nameIts own part of its name: renaming customer.email to phone makes customer.phone
requiredPDF apps ask for a value before the form is submitted
readOnlyPeople can’t change the value
alternateNameThe tooltip, also read by screen readers
defaultValueWhat a reset puts back
optionsFor a dropdown or list: { label, value } pairs
maxLength, multiline, password, combFor 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 dragged

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

MethodWhat 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#

select-new-field.tsx
import { useFormEvent } from '@embedpdf/react/form';
import { selectField } from './editor';

export function SelectNewFields() {
  useFormEvent(
    (form) => form.onFieldCreated,
    ({ field }) => selectField(field),
  );

  return null;
}
EventFires whenCarries
onFieldCreatedA field was addedfield, origin
onFieldUpdatedA field’s settings changedfield, origin
onFieldDeletedA field was removedref, origin

Permissions#

PermissionWhat it allowsCheck
doc.forms.modifyAdding, changing, removing and repairing fieldsform.canDesign()
doc.forms.importImporting fields and values with who created and filled them inform.canRestoreAttribution()
doc.forms.scriptWriting scripts, submit buttons and links into fields and boxesform.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.

Was this page helpful?

Your feedback goes directly to the documentation team.