# 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`.

For Vanilla, replace the contents of `<body>` in `index.html` with the Editor and panel below:

```html
<main class="fields-layout">
  <div id="editor" style="height: 70vh; overflow: auto"></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;
    let mutationInFlight = false;

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

    let catalogRequest = 0;
    const readFields = async (
      snapshot: ContentControlsSlice,
      failureMessage = 'The field list could not be refreshed.',
    ) => {
      const request = ++catalogRequest;
      try {
        const catalog = await documentApi.contentControls.list();
        if (request !== catalogRequest) return;
        render({ ...snapshot, status: 'ready', items: catalog.items, total: catalog.total });
      } catch {
        if (request !== catalogRequest) return;
        if (!mutationInFlight) failMutation(failureMessage);
      }
    };

    const refreshFields = async () => {
      await readFields(
        ui.contentControls.getSnapshot(),
        'The document changed, but the field list could not be refreshed.',
      );
    };

    const updateTextField = async (control: ContentControlInfo, value: string) => {
      if (pendingMutation) return;
      mutationInFlight = true;
      catalogRequest += 1;
      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 });
        mutationInFlight = false;
        if (!receipt.success) failMutation(receipt.failure.message);
        else await refreshFields();
      } catch (error) {
        mutationInFlight = false;
        failMutation(error instanceof Error ? error.message : `${name} could not be updated.`);
      }
    };

    const updateCheckbox = async (control: ContentControlInfo, checked: boolean) => {
      if (pendingMutation) return;
      mutationInFlight = true;
      catalogRequest += 1;
      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 });
        mutationInFlight = false;
        if (!receipt.success) failMutation(receipt.failure.message);
        else await refreshFields();
      } catch (error) {
        mutationInFlight = false;
        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 && !mutationInFlight) {
        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.disabled = locked || pendingMutation !== null || input.value === currentValue;
          });
          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);
      }
    };

    const stop = ui.contentControls.observe((snapshot) => {
      void readFields(snapshot);
    });
    stopContentControls = () => {
      catalogRequest += 1;
      stop();
    };
  },
  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, useRef, useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { ContentControlInfo, ContentControlsSlice } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, 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, setFields] = useState<ContentControlsSlice>({
    status: 'pending',
    items: [],
    total: 0,
    activeId: null,
    activeIds: [],
  });
  const [drafts, setDrafts] = useState<Record<string, string>>({});
  const [pendingMutation, setPendingMutation] = useState<PendingMutation | null>(null);
  const [status, setStatus] = useState('Choose a field to edit it.');
  const catalogRequest = useRef(0);
  const mutationInFlight = useRef(false);

  async function readFields(snapshot: ContentControlsSlice, failureMessage = 'The field list could not be refreshed.') {
    const request = ++catalogRequest.current;
    try {
      const catalog = await host?.activeEditor?.doc?.contentControls?.list?.();
      if (!catalog) throw new Error('Field catalog unavailable.');
      if (request !== catalogRequest.current) return;
      setFields({ ...snapshot, status: 'ready', items: catalog.items, total: catalog.total });
    } catch {
      if (request !== catalogRequest.current) return;
      if (mutationInFlight.current) return;
      setPendingMutation(null);
      setStatus(failureMessage);
    }
  }

  useEffect(() => {
    if (!ui) return;
    const stop = ui.contentControls.observe((snapshot) => {
      void readFields(snapshot);
    });
    return () => {
      catalogRequest.current += 1;
      stop();
    };
  }, [ui, host]);

  async function refreshFields() {
    if (!ui) {
      setPendingMutation(null);
      setStatus('Editor unavailable.');
      return;
    }
    await readFields(
      ui.contentControls.getSnapshot(),
      'The document changed, but the field list could not be refreshed.',
    );
  }

  useEffect(() => {
    if (!pendingMutation || mutationInFlight.current) 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) {
    if (pendingMutation || mutationInFlight.current) return;
    const documentApi = host?.activeEditor?.doc;
    if (!documentApi?.contentControls?.text?.setValue) {
      setStatus('Text field editing is unavailable.');
      return;
    }

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

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

    const name = fieldName(control);
    const mutation: PendingMutation = { checked, controlId: control.id, controlName: name, kind: 'checkbox' };
    mutationInFlight.current = true;
    catalogRequest.current += 1;
    setPendingMutation(mutation);
    setStatus(`Updating ${name}…`);
    try {
      const receipt = await documentApi.contentControls.checkbox.setState({ target: control.target, checked });
      mutationInFlight.current = false;
      if (!receipt.success) {
        setPendingMutation(null);
        setStatus(receipt.failure.message);
      } else await refreshFields();
    } catch (error) {
      mutationInFlight.current = false;
      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. Confirm the updated field value before clearing the draft and reporting success.

In `superdoc@2.12.0`, the observed catalog can remain stale after a programmatic update. These examples read
`contentControls.list()` after mutations and observer notifications, ignoring older responses. A failed refresh reports
that the document changed but the panel could not refresh; it does not report the mutation as failed.

## Highlight a field from your panel [#highlight-a-field-from-your-panel]

Use `highlight()` when a pointer enters a panel row, and `clearHighlight()` when it leaves:

```ts
row.addEventListener('pointerenter', () => {
  void ui.contentControls.highlight({ id: control.id });
});
row.addEventListener('pointerleave', () => ui.contentControls.clearHighlight());
```

The browser Editor highlights the control's text across paragraphs and table cells without moving focus, selection,
or scroll. One highlight is active at a time. A successful request replaces it; a failed request preserves it.
The result is `{ success: true }` or `{ success: false, reason }`. Unknown IDs, empty controls, and unavailable Editors
fail explicitly. Offscreen text appears highlighted when its page mounts. Clearing cancels pending requests, and
opening another document clears the highlight. This also works in viewing mode and does not change saved document content.

The default background is yellow (`#ffff00`). Set a CSS variable on your Editor wrapper to override it:

```css
.contract-editor {
  --sd-content-controls-highlight-bg: #b9e8ff;
}
```

No JavaScript color option is needed. Removing the variable restores the yellow default.

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