# Build a document field panel

> Observe document fields, navigate to each one, and update typed values from application UI.



Built-in content-control chrome helps someone recognize and click a field in the document. Build a custom panel when
your application needs to keep every field visible, move between them, or render a field-specific input.

This guide replaces only the field panel. SuperDoc continues to render the toolbar, document, selection, and
content-control chrome.

## Try the field workflow [#try-the-field-workflow]

The example loads a text field and a checkbox on two short pages.

1. Change **Client name**, then choose **Update**. The value changes in the document.
2. Choose **Show in document** for **Review approved**. The Editor moves to the checkbox on page 2.
3. Check **Approved**, then return to **Client name** with **Show in document**.

> **Live example: edit document fields from an application-owned panel**
>
> SuperDoc renders its toolbar and a two-page DOCX while the application renders a persistent field panel. Show in document moves between a text field on page 1 and a checkbox on page 2. The panel observes `ui.contentControls`, runs `activeEditor.doc.contentControls.text.setValue()` or `checkbox.setState()`, and stays pending until the observed field contains the new value.


The panel follows the same custom-UI loop as Comments and Track changes: observe Editor state, render application
controls, run an action, and wait for the observed state to confirm the result.

## Build the same panel [#build-the-same-panel]

Start from [Build your first custom control](/editor/custom-ui/controller-setup). Download the
[two-page field fixture](/fixtures/custom-content-controls-workflow.docx) to your app's `public` directory as
`contract.docx`.

Vanilla adds the panel beside its existing Editor container. Replace `index.html` with:

```html
<main class="fields-layout">
  <div id="editor" style="height: 70vh"></div>

  <aside aria-labelledby="fields-heading">
    <h2 id="fields-heading">Document fields</h2>
    <p id="fields-count">Opening document...</p>
    <ul id="field-list"></ul>
    <p id="fields-status" role="status">Choose a field to edit it.</p>
  </aside>
</main>

<script type="module" src="/src/main.ts"></script>

```

Replace the setup guide's Editor code:

**Vanilla — `src/main.ts`**

```ts
import { SuperDoc } from 'superdoc';
import type { ContentControlInfo, ContentControlsSlice } from 'superdoc/ui';
import 'superdoc/style.css';

type PendingMutation =
  | { checked: boolean; controlId: string; controlName: string; kind: 'checkbox' }
  | { controlId: string; controlName: string; kind: 'text'; value: string };

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing field panel element: ${selector}`);
  return element;
}

function fieldName(control: ContentControlInfo) {
  return control.properties.alias ?? control.properties.tag ?? control.controlType;
}

function isContentLocked(control: ContentControlInfo) {
  return control.lockMode === 'contentLocked' || control.lockMode === 'sdtContentLocked';
}

function mutationIsObserved(control: ContentControlInfo, mutation: PendingMutation) {
  if (control.id !== mutation.controlId) return false;
  if (mutation.kind === 'checkbox') return control.properties.checked === mutation.checked;
  return control.text === mutation.value;
}

const fieldCount = getElement<HTMLParagraphElement>('#fields-count');
const fieldList = getElement<HTMLUListElement>('#field-list');
const fieldsStatus = getElement<HTMLParagraphElement>('#fields-status');

const drafts = new Map<string, string>();
let pendingMutation: PendingMutation | null = null;
let stopContentControls: (() => void) | null = null;

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: ({ superdoc: readySuperDoc }) => {
    stopContentControls?.();
    const documentApi = readySuperDoc.activeEditor?.doc;
    if (!documentApi) throw new Error('The Document API is not ready.');

    const { ui } = readySuperDoc;

    const showField = async (control: ContentControlInfo) => {
      if (pendingMutation) return;
      const name = fieldName(control);
      const result = await ui.contentControls.focus({ id: control.id });
      fieldsStatus.textContent = result.success
        ? `Showing ${name} in the document.`
        : `${name} could not be shown in the document.`;
    };

    // Rerender from the last observed snapshot so pending state changes are
    // reflected without an extra catalog read.
    let lastControls: ContentControlsSlice | null = null;

    const failMutation = (message: string) => {
      pendingMutation = null;
      fieldsStatus.textContent = message;
      if (lastControls) render(lastControls);
    };

    const updateTextField = async (control: ContentControlInfo, value: string) => {
      const name = fieldName(control);
      // Keep the submitted value as the draft so a failed update does not
      // reset the input to the document's old text.
      drafts.set(control.id, value);
      pendingMutation = { controlId: control.id, controlName: name, kind: 'text', value };
      fieldsStatus.textContent = `Updating ${name}…`;
      if (lastControls) render(lastControls);

      try {
        const receipt = await documentApi.contentControls.text.setValue({ target: control.target, value });
        if (!receipt.success) failMutation(receipt.failure.message);
      } catch (error) {
        failMutation(error instanceof Error ? error.message : `${name} could not be updated.`);
      }
    };

    const updateCheckbox = async (control: ContentControlInfo, checked: boolean) => {
      const name = fieldName(control);
      pendingMutation = { checked, controlId: control.id, controlName: name, kind: 'checkbox' };
      fieldsStatus.textContent = `Updating ${name}…`;
      if (lastControls) render(lastControls);

      try {
        const receipt = await documentApi.contentControls.checkbox.setState({ target: control.target, checked });
        if (!receipt.success) failMutation(receipt.failure.message);
      } catch (error) {
        failMutation(error instanceof Error ? error.message : `${name} could not be updated.`);
      }
    };

    const render = (controls: ContentControlsSlice) => {
      lastControls = controls;
      const currentMutation = pendingMutation;
      const observedMutation =
        currentMutation && controls.items.find((control) => mutationIsObserved(control, currentMutation));
      if (currentMutation && observedMutation) {
        const completedMutation = currentMutation;
        pendingMutation = null;
        if (completedMutation.kind === 'text') drafts.delete(completedMutation.controlId);
        fieldsStatus.textContent =
          completedMutation.kind === 'checkbox'
            ? `${completedMutation.controlName} ${completedMutation.checked ? 'checked' : 'unchecked'}.`
            : `${completedMutation.controlName} updated.`;
      }

      fieldCount.textContent = controls.status === 'pending' ? 'Loading fields…' : `${controls.total} document fields`;
      fieldList.replaceChildren();

      for (const control of controls.items) {
        const row = document.createElement('li');
        const label = document.createElement('strong');
        const show = document.createElement('button');
        const name = fieldName(control);
        const locked = isContentLocked(control);

        label.textContent = name;
        show.type = 'button';
        show.textContent = controls.activeIds.includes(control.id) ? 'Showing' : 'Show in document';
        show.disabled = pendingMutation !== null;
        show.addEventListener('click', () => void showField(control));
        row.append(label, show);

        if (control.controlType === 'text') {
          const input = document.createElement('input');
          const update = document.createElement('button');
          const currentValue = control.text ?? '';

          input.type = 'text';
          input.value = drafts.get(control.id) ?? currentValue;
          input.disabled = locked || pendingMutation !== null;
          input.setAttribute('aria-label', `Value for ${name}`);
          input.addEventListener('input', () => drafts.set(control.id, input.value));
          update.type = 'button';
          update.textContent = pendingMutation?.controlId === control.id ? 'Updating…' : 'Update';
          update.disabled = locked || pendingMutation !== null || input.value === currentValue;
          update.addEventListener('click', () => void updateTextField(control, input.value));
          row.append(input, update);
        }

        if (control.controlType === 'checkbox') {
          const checkboxLabel = document.createElement('label');
          const checkbox = document.createElement('input');

          checkbox.type = 'checkbox';
          checkbox.checked =
            pendingMutation?.kind === 'checkbox' && pendingMutation.controlId === control.id
              ? pendingMutation.checked
              : (control.properties.checked ?? false);
          checkbox.disabled = locked || pendingMutation !== null;
          checkbox.addEventListener('change', () => void updateCheckbox(control, checkbox.checked));
          checkboxLabel.append(checkbox, ' Approved');
          row.append(checkboxLabel);
        }

        fieldList.append(row);
      }
    };

    stopContentControls = ui.contentControls.observe(render);
  },
  onContentError: ({ error }) => {
    fieldsStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
  onException: ({ error }) => {
    fieldsStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
});

window.addEventListener('beforeunload', () => {
  stopContentControls?.();
  superdoc.destroy();
});

```

**React — `src/App.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { ContentControlInfo } from 'superdoc/ui';
import {
  SuperDocUIProvider,
  useSetSuperDoc,
  useSuperDocContentControls,
  useSuperDocHost,
  useSuperDocUI,
} from 'superdoc/ui/react';
import '@superdoc/react/style.css';

type PendingMutation =
  | { checked: boolean; controlId: string; controlName: string; kind: 'checkbox' }
  | { controlId: string; controlName: string; kind: 'text'; value: string };

function fieldName(control: ContentControlInfo) {
  return control.properties.alias ?? control.properties.tag ?? control.controlType;
}

function isContentLocked(control: ContentControlInfo) {
  return control.lockMode === 'contentLocked' || control.lockMode === 'sdtContentLocked';
}

function mutationIsObserved(control: ContentControlInfo, mutation: PendingMutation) {
  if (control.id !== mutation.controlId) return false;
  if (mutation.kind === 'checkbox') return control.properties.checked === mutation.checked;
  return control.text === mutation.value;
}

export default function App() {
  return (
    <SuperDocUIProvider>
      <main className='fields-layout'>
        <Editor />
        <FieldPanel />
      </main>
    </SuperDocUIProvider>
  );
}

function Editor() {
  const setSuperDoc = useSetSuperDoc();

  return (
    <SuperDocEditor
      document='/contract.docx'
      onContentError={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onException={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onReady={({ superdoc }) => setSuperDoc(superdoc)}
    />
  );
}

function FieldPanel() {
  const host = useSuperDocHost();
  const ui = useSuperDocUI();
  const fields = useSuperDocContentControls();
  const [drafts, setDrafts] = useState<Record<string, string>>({});
  const [pendingMutation, setPendingMutation] = useState<PendingMutation | null>(null);
  const [status, setStatus] = useState('Choose a field to edit it.');

  useEffect(() => {
    if (!pendingMutation) return;
    const updatedField = fields.items.find((field) => mutationIsObserved(field, pendingMutation));
    if (!updatedField) return;

    if (pendingMutation.kind === 'text') {
      setDrafts((current) => {
        const next = { ...current };
        delete next[pendingMutation.controlId];
        return next;
      });
    }
    setStatus(
      pendingMutation.kind === 'checkbox'
        ? `${pendingMutation.controlName} ${pendingMutation.checked ? 'checked' : 'unchecked'}.`
        : `${pendingMutation.controlName} updated.`,
    );
    setPendingMutation(null);
  }, [fields.items, pendingMutation]);

  async function showField(control: ContentControlInfo) {
    if (!ui || pendingMutation) return;
    const name = fieldName(control);
    const result = await ui.contentControls.focus({ id: control.id });
    setStatus(result.success ? `Showing ${name} in the document.` : `${name} could not be shown in the document.`);
  }

  async function updateTextField(control: ContentControlInfo, value: string) {
    const documentApi = host?.activeEditor?.doc;
    if (!documentApi?.contentControls?.text?.setValue) {
      setStatus('Text field editing is unavailable.');
      return;
    }

    const name = fieldName(control);
    setPendingMutation({ controlId: control.id, controlName: name, kind: 'text', value });
    setStatus(`Updating ${name}…`);
    try {
      const receipt = await documentApi.contentControls.text.setValue({ target: control.target, value });
      if (!receipt.success) {
        setPendingMutation(null);
        setStatus(receipt.failure.message);
      }
    } catch (error) {
      setPendingMutation(null);
      setStatus(error instanceof Error ? error.message : `${name} could not be updated.`);
    }
  }

  async function updateCheckbox(control: ContentControlInfo, checked: boolean) {
    const documentApi = host?.activeEditor?.doc;
    if (!documentApi?.contentControls?.checkbox?.setState) {
      setStatus('Checkbox editing is unavailable.');
      return;
    }

    const name = fieldName(control);
    setPendingMutation({ checked, controlId: control.id, controlName: name, kind: 'checkbox' });
    setStatus(`Updating ${name}…`);
    try {
      const receipt = await documentApi.contentControls.checkbox.setState({ target: control.target, checked });
      if (!receipt.success) {
        setPendingMutation(null);
        setStatus(receipt.failure.message);
      }
    } catch (error) {
      setPendingMutation(null);
      setStatus(error instanceof Error ? error.message : `${name} could not be updated.`);
    }
  }

  return (
    <aside aria-labelledby='fields-heading'>
      <h2 id='fields-heading'>Document fields</h2>
      <p>{fields.status === 'pending' ? 'Loading fields…' : `${fields.total} document fields`}</p>

      <ul>
        {fields.items.map((field) => {
          const name = fieldName(field);
          const locked = isContentLocked(field);
          const draft = drafts[field.id] ?? field.text ?? '';
          const checked =
            pendingMutation?.kind === 'checkbox' && pendingMutation.controlId === field.id
              ? pendingMutation.checked
              : (field.properties.checked ?? false);

          return (
            <li aria-current={fields.activeIds.includes(field.id) ? 'true' : undefined} key={field.id}>
              <strong>{name}</strong>
              <button disabled={pendingMutation !== null} onClick={() => void showField(field)} type='button'>
                {fields.activeIds.includes(field.id) ? 'Showing' : 'Show in document'}
              </button>

              {field.controlType === 'text' && (
                <>
                  <input
                    aria-label={`Value for ${name}`}
                    disabled={locked || pendingMutation !== null}
                    onChange={(event) => setDrafts((current) => ({ ...current, [field.id]: event.target.value }))}
                    type='text'
                    value={draft}
                  />
                  <button
                    disabled={locked || pendingMutation !== null || draft === (field.text ?? '')}
                    onClick={() => void updateTextField(field, draft)}
                    type='button'
                  >
                    {pendingMutation?.kind === 'text' && pendingMutation.controlId === field.id
                      ? 'Updating…'
                      : 'Update'}
                  </button>
                </>
              )}

              {field.controlType === 'checkbox' && (
                <label>
                  <input
                    checked={checked}
                    disabled={locked || pendingMutation !== null}
                    onChange={(event) => void updateCheckbox(field, event.target.checked)}
                    type='checkbox'
                  />
                  Approved
                </label>
              )}
            </li>
          );
        })}
      </ul>

      <p aria-live='polite' role='status'>
        {status}
      </p>
    </aside>
  );
}

```


## Connect field state to typed mutations [#connect-field-state-to-typed-mutations]

The panel uses two public surfaces with different jobs:

* `ui.contentControls` supplies the observed field catalog, active field IDs, and `focus()` navigation.
* `activeEditor.doc.contentControls` supplies type-specific document mutations. This example uses `text.setValue()` and
  `checkbox.setState()`.

Each catalog item carries its `controlType`, `lockMode`, properties, current value, and mutation target. Use those values
instead of inspecting the rendered document DOM.

Keep every action disabled while a mutation is pending. A successful receipt confirms that the Document API accepted
the mutation. Keep the panel pending until the observer returns the updated value, then clear any local draft and report
success. If the receipt fails or the operation throws, clear the pending state and show the failure.

## Render the field type you support [#render-the-field-type-you-support]

This panel handles text fields and checkboxes. Date, choice, rich-text, and repeating controls have their own operations.
Render an input only when its `controlType` matches an operation your panel implements. Disable content mutations for
`contentLocked` and `sdtContentLocked` fields, and still inspect every mutation receipt.

Use [Content controls](/editor/content-controls) to create template fields, fill repeated values, and choose lock modes.
The [Document API reference](/document-api/reference/content-controls/) lists every content-control type and operation.

## Verify the field panel [#verify-the-field-panel]

Run the project. Move to the checkbox on page 2, change it, then return to the text field on page 1 and update its value.
Each successful mutation should appear in both the document and the panel before its status changes from pending.

Return to the [Custom UI overview](/editor/custom-ui/overview) to choose another workflow.
