Custom UI

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

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.

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

Start from Build your first custom control. Download the two-page field fixture 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:

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

src/main.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();
});

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 [email protected], 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.

Read active fields after selection changes

ui.selection and ui.contentControls settle independently. Read each snapshot's status:

StatusMeaning
pendingThe read has not settled; an empty list is an initial value.
staleA refresh is running; the snapshot contains previously settled, best-known data.
readyThe underlying reads have settled for the current state.

A ready caret selection can coexist with a stale content-controls snapshot whose activeIds is empty, even when items lists an enclosing control. This can occur inside onException after a rejected keystroke in a locked field. Empty stale IDs do not establish that the caret is outside every control. Observe the controls slice becoming ready for current active IDs; another selection or document change can start a new refresh.

const stopObserving = ui.contentControls.observe((snapshot) => {
  if (snapshot.status !== 'ready') return;
  const activeFields = snapshot.activeIds.flatMap((id) => {
    const field = ui.contentControls.get({ id });
    return field ? [field] : [];
  });
  // Render activeFields in your application panel.
});
// Call stopObserving() when the panel is removed.

Read cached context inside a rejection callback

getSnapshot(), list(), and get({ id }) read loaded controller state synchronously. They do not synchronously refresh the catalog or identify the cause of a rejected edit. The callback has no guaranteed lock-specific reason or rejecting-control ID. In particular, INVALID_CONTEXT has several causes, and an active field describes the selection rather than proving which control rejected an operation. See Handle rejected edits.

For a known, unchanged catalog, compare a ready caret with cached selectionTarget ranges to obtain field context. The helper below handles one unambiguous inline control in a body paragraph, strictly inside its text. It returns null for unknown freshness, missing targets, non-caret selections, other stories, multi-block controls, mismatched coordinate spaces, boundary carets, or multiple enclosing controls. An omitted story means body; an omitted coordinate space means visible.

import type { ContentControlInfo, ContentControlsHandle, SelectionSlice, SelectionTarget } from 'superdoc/ui';

function isBodyRange(target: SelectionTarget): boolean {
  const stories = [
    target.story,
    ...[target.start, target.end].map((point) => (point.kind === 'text' ? point.story : point.node.story)),
  ];
  return stories.every((story) => story === undefined || story.storyType === 'body');
}

// Context only: the caller must know that cached IDs and ranges are still current.
export function cachedControlAtCaret(
  controls: ContentControlsHandle,
  selection: SelectionSlice,
  catalogKnownCurrent: boolean,
): ContentControlInfo | null {
  const caret = selection.selectionTarget;
  if (!catalogKnownCurrent || selection.status !== 'ready' || !caret || !isBodyRange(caret)) return null;
  const { start, end } = caret;
  if (start.kind !== 'text' || end.kind !== 'text' || start.blockId !== end.blockId || start.offset !== end.offset) {
    return null;
  }

  const matches = controls.getSnapshot().items.filter((control) => {
    const range = control.selectionTarget;
    return (
      control.kind === 'inline' &&
      range &&
      isBodyRange(range) &&
      (range.coordinateSpace ?? 'visible') === (caret.coordinateSpace ?? 'visible') &&
      range.start.kind === 'text' &&
      range.end.kind === 'text' &&
      range.start.blockId === start.blockId &&
      range.end.blockId === start.blockId &&
      range.start.offset < start.offset &&
      start.offset < range.end.offset
    );
  });

  // Multiple enclosing controls and boundary affinity need a different workflow.
  return matches.length === 1 ? controls.get({ id: matches[0].id }) : null;
}

Call cachedControlAtCaret(ui.contentControls, ui.selection.getSnapshot(), catalogKnownCurrent) inside your rejection handler. Set catalogKnownCurrent only when your integration knows the loaded IDs and ranges have not changed since a settled catalog was obtained. A previously ready snapshot alone cannot guarantee that after later edits. Leave it false if freshness is unknown. A returned field's lockMode supplies context for that field; it is not causal proof. For authoritative current field state, refresh through the Document API or observe a ready slice outside the synchronous callback.

Shared editing

The same per-slice readiness and generic rejection contract applies in a supported V2 shared document. Content-control state refreshes when remote document changes are observed, so cached ranges also require freshness against remote edits. Each client's activeIds follows its own selection; another client's caret does not become locally active. A rejected keystroke inside a content-locked inline field leaves shared text unchanged on both clients.

React to a field click

ui.contentControls.onClick reports each click inside a content control. When controls are nested, target is the innermost one:

const stopClicks = ui.contentControls.onClick(({ target }) => {
  // Select target.id in your panel.
});
// Call stopClicks() when the panel is removed.

Use it instead of the slices when the click itself is the event. Clicking the field the caret is already in leaves activeIds unchanged, so observe and observeActivePath report nothing; onClick reports every click. The payload is { target, source: 'pointer' }, where target carries the control's id, tag, alias, controlType, and scope.

This replaces the content-control:click instance event and the onContentControlClick configuration callback. Both still work and are deprecated for removal in v3.0.

Highlight a field from your panel

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

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:

.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

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 to create template fields, fill repeated values, and choose lock modes. The Document API reference lists every content-control type and operation.

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 to choose another workflow.

On this page