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.
- Change Client name, then choose Update. The value changes in the document.
- Choose Show in document for Review approved. The Editor moves to the checkbox on page 2.
- 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.
Vanilla adds the panel beside its existing Editor container. Replace index.html with:
<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:
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();
});
Connect field state to typed mutations
The panel uses two public surfaces with different jobs:
ui.contentControlssupplies the observed field catalog, active field IDs, andfocus()navigation.activeEditor.doc.contentControlssupplies type-specific document mutations. This example usestext.setValue()andcheckbox.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
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.