Content controls
Find, fill, create, and lock DOCX content controls from a server script or inside the editor.
DOCX content controls (w:sdt) mark fillable regions. Tag them in a template, then find each control by tag, title, or id. The editor preserves unsupported properties, such as w15:repeatingSection, on save.
Content controls are not {{ mustache }} template variables. The two systems can coexist in one
document.
Fill a control from code
@docx-editor.dev/editor-api fills controls on a server. Headless automation requires an EigenPal Pro License. Load the tags, then commit all writes in one final sync:
import { readFile, writeFile } from 'node:fs/promises';
import { DocxEditor } from '@docx-editor.dev/editor-api';
const runtime = await DocxEditor.createServer(await readFile('contract-template.docx'));
try {
await runtime.run(async (context) => {
const controls = context.document.body.contentControls;
controls.load('items/tag');
await context.sync(); // Read the control tags.
for (const control of controls.items) {
if (control.tag === 'customerName') control.setValue({ kind: 'text', text: 'Acme GmbH' });
if (control.tag === 'effective') control.setValue({ kind: 'date', iso: '2026-07-01' });
if (control.tag === 'agree') control.setValue({ kind: 'checkbox', checked: true });
if (control.tag === 'betaClause') control.delete(false); // condition not met
}
await context.sync(); // Commit all writes together.
});
await writeFile('contract-acme.docx', Buffer.from(await runtime.save()));
} finally {
runtime.dispose();
}getByTag(tag) narrows the collection. getById(id) and getFirstOrNullObject() address one control. For signatures and batching rules, see Content controls in the Editing API.
A value write keeps the enclosing cell, row, and paragraph formatting:
- Text, list, and date writes replace one paragraph's content with one run and remove its sibling paragraphs.
- The kept paragraph is the first one with text, or the first one if none has text. It keeps its bookmarks and comment anchors.
- Checkbox writes change only the state glyph.
A write that would remove a sibling table or nested control, cross a field, or discard unsupported markup fails and leaves the document unchanged.
Choose a value operation
insertText() and delete() are Office.js members. setValue() is a DocxEditor extension, not an Office.js member.
| Operation | Kind | Use for |
|---|---|---|
insertText(text, 'Replace' | 'Start' | 'End') | Office.js | Free text. |
delete(keepContent) | Office.js | Remove the control with its content, or unwrap it. |
setValue({ kind: 'text', text }) | Extension | Rich-text and plain-text controls. |
setValue({ kind: 'listItem', value }) | Extension | Dropdowns and combo boxes. Must match a declared item. |
setValue({ kind: 'checkbox', checked }) | Extension | Checkbox controls. |
setValue({ kind: 'date', iso }) | Extension | Date pickers. YYYY-MM-DD or a full ISO-8601 instant. |
Load properties before you read them. placeholderShown is true while the control shows its prompt instead of entered data:
controls.load('items/placeholderShown');
await context.sync();
const unfilled = controls.items.filter((c) => c.placeholderShown);cannotEdit and cannotDelete expose the control's locks. Set them when you author a template.
Inspect and create controls
useContentControl() reports the control at the caret and whether you can write it:
import { useContentControl } from '@docx-editor.dev/react';
function ControlInspector() {
const { control, setValue, canSetValue, setValueDisabledReason } = useContentControl();
if (!control) return null;
return (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
disabled={!canSetValue}
title={setValueDisabledReason ?? undefined}
onClick={() => setValue('Acme GmbH')}
>
Fill {control.alias ?? control.tag ?? control.id}
</button>
);
}<script setup lang="ts">
import { useContentControl } from '@docx-editor.dev/vue';
const { control, setValue, canSetValue, setValueDisabledReason } = useContentControl();
</script>
<template>
<button
v-if="control"
type="button"
:disabled="!canSetValue"
:title="setValueDisabledReason ?? undefined"
@mousedown.prevent
@click="setValue('Acme GmbH')"
>
Fill {{ control.alias ?? control.tag ?? control.id }}
</button>
</template>| Member | Purpose |
|---|---|
control | tag, alias, id, controlType, locked, removalLocked, effectiveLock, bound, placeholder. |
setValue, canSetValue, setValueDisabledReason | Write the value, or read why the write is refused. |
remove, canRemove | Remove the control. |
showAll, setShowAll | Show boundaries for every control. |
formFill, setFormFill | Tab and Shift+Tab move between editable controls. |
The packaged ContentControl part uses this hook. CONTENT_CONTROL_SLOTS lists its chrome slot IDs.
Run insertContentControl to create a control as one undo step. It wraps the selection, or inserts an empty control with a prompt:
editor.exec({
type: 'insertContentControl',
subtype: 'plainText',
tag: 'customerName',
title: 'Customer name',
});subtype accepts richText, plainText, dropdown, comboBox, and date. Pass target: { paraId, search } to wrap a phrase other than the selection. New dropdowns and combo boxes have no items.
editor.can() returns the same refusal as exec():
| Operation | Refusal condition | Reason |
|---|---|---|
| Fill or edit | The control or an ancestor locks its content. | locked |
| Remove | The control or an ancestor locks its wrapper, or an enclosing control locks its content. | locked |
| Fill | The control declares w:dataBinding. | bound |
| Fill | The value does not match the control type. | typeMismatch |
| Create | The selection crosses paragraphs. | Inline controls cannot wrap block content. |
| Create | The position is inside a hyperlink, field, or inline control. | No valid sibling position. |
Configure locks and form navigation
These creation options and props change lock behavior. The editor reads each one once, at creation:
| Option | Effect |
|---|---|
lockedControlNavigation: 'atomic' | Content-locked inline controls become one caret unit. |
contentLockPolicy: { allowFormattingInLockedContent: true } | Run formatting passes the content lock. |
formFill changes Tab navigation only. It does not prevent edits outside controls. Use document protection to restrict edits.
Caret movement in content-locked controls
By default, the caret walks through each character of a content-locked inline control. Set lockedControlNavigation to 'atomic' to step over it:
const editor = createDocxEditor({
container,
document: bytes,
lockedControlNavigation: 'atomic',
});React and Vue accept the same lockedControlNavigation prop. With 'atomic', arrows step over a contentLocked or sdtContentLocked control, and a press inside selects the whole control.
- The option applies in the body, headers, and footers. Notes and text boxes keep character navigation.
- Selections set from code, such as
setSelection, stay where you put them. - The option changes navigation only. Locks refuse the same edits.
- With
pointer: 'native', the browser places the caret. Pointer gestures do not treat the control as one unit.
Formatting content-locked controls
A content lock refuses text edits and character formatting with code locked. It also refuses paragraph styles, lists, text direction, tab stops, and contextual spacing. Alignment, indents, spacing, and break settings stay available. In suggesting mode, they refuse with locked when the paragraph already has a suggested change that records a locked property, such as a style.
Set contentLockPolicy to let authors style locked text:
const editor = createDocxEditor({
container,
document: bytes,
contentLockPolicy: { allowFormattingInLockedContent: true },
});
const host = createServerAutomationHost(bytes, {
contentLockPolicy: { allowFormattingInLockedContent: true },
});React and Vue accept the same contentLockPolicy prop. Bold, italic, color, font, size, and highlight then pass the lock.
- Typing, deletion, paragraph styles, and lists still refuse with
locked. - Tracked character formatting in suggesting mode still refuses.
- Data-bound controls still refuse character formatting with
bound. - The option does not remove saved locks or binding restrictions. The saved
w:lockvalue stays the same. - The option applies to local edits only. Collaborators receive permitted formatting even when their own policy refuses it.
Customize value entry
Replace the value popups through these popups entries:
| Popup entry | Controls |
|---|---|
contentControl | The control inspector |
contentControlWidget | Dropdown, combo box, date, and building block gallery |
contentControlCheckbox | Checkbox |
contentControlPicture | Picture |
Omit an entry to keep the default popup. DocxEditorContentControlWidget provides composable parts, and useContentControlWidget() exposes draft state and actions. For examples, see Compose the value popup.
Style the default menus with the docx-content-control-menu, docx-content-control-menu-item, docx-content-control-calendar-*, and docx-content-control-widget classes and --doc-* tokens. The date picker follows the editor's locale.
Limits
- Data-bound controls show the bound value when the document opens. Direct writes refuse with
bound. - Commands that create, fill, or remove controls do not create revisions.
- In suggesting mode, delete gestures on a
contentLockedcontrol propose one tracked deletion. - Picture picks reject files larger than 32 MiB. Image replacement refuses in suggesting mode.
- Canceling, closing the editor, or entering suggesting mode during decoding prevents the picture write.
- Picture replacement keeps the drawing's size, crop, name, and alt text. A different aspect ratio can stretch.
- Controls that wrap a whole table cell or row work like other controls. Value writes keep the table structure.
- Controls inside a hyperlink are not surfaced.
- Typing at the end of an unlocked text control stays inside it. Typing at the end of a hyperlink inside a control leaves only the hyperlink.
- Repeating sections round-trip without item add, remove, or configuration edits.
- Building block galleries list only blocks in the document's own glossary part.
- An inline control accepts only a one-paragraph building block. A block with several paragraphs or a table needs a block-level control.
- A gallery pick supports self-contained text, direct formatting, math, tables, and unbound nested controls. A block that references styles, numbering, media, relationships, bookmarks, notes, fields, or revisions returns
unsupported, even when the main document has a style with the same name. - Forms protection permits edits inside unlocked controls. Read-only and comments-only protection refuse control writes.
- For legacy form fields, see Legacy text form fields.
- Binding resolution and glossary support follow DOCX fidelity.
Data-bound controls refresh their value from the bound store:
| Aspect | Behavior |
|---|---|
| Stores | Custom XML parts in the package, and the core and extended document properties. |
| XPath | Absolute paths of child elements, with optional positions such as [2]. The last step can select an element, an attribute, or text(). |
| Plain text values | Single-line controls drop line breaks. Multiline controls keep them. |
| Checkbox values | true, false, 1, or 0. |
| Saved content kept | Rich text controls, controls with no type, unsupported values, and unsupported XPath. |
| Collaboration | The participant that creates the room refreshes the controls. Other participants use the room's content. |