Content controls
Find and fill Word content controls by tag, alias, or id. Set text, dropdown, checkbox, and date values and manage repeating sections, headless or live.
Word content controls (w:sdt, Structured Document Tags) are labeled, bounded regions of a document. A block-level control wraps one or more paragraphs or a table and carries a stable tag, alias, and id. That stability makes them the natural anchor for templates, conditional sections, and document automation: design the template in Word, tag the fillable regions, then fill them by tag from code.
The editor parses block controls into the document model, keeps them editable, renders their boundary, and round-trips them losslessly on save, including unmodeled properties such as w:dataBinding and w15:repeatingSection.
Content controls are addressed by tag, alias, or id. They are not the {{mustache}} template variables handled by the docxtemplater plugin; the two systems coexist in one document.
Finding controls
Headless (no DOM, no editor), against a parsed Document:
import {
parseDocx,
findContentControls,
findContentControl,
} from "@docx-editor.dev/core/headless";
const doc = await parseDocx(buffer);
const all = findContentControls(doc); // ContentControlInfo[]
const checkboxes = findContentControls(doc, { type: "checkbox" });
const intro = findContentControl(doc, { tag: "intro" }); // first match or undefinedThe filter is { tag?, alias?, id?, type? }. Each ContentControlInfo carries tag, alias, id, sdtType, lock, listItems, placeholder, text, plus modeled state: showingPlaceholder, checked, dateFormat, and dataBinding.
When showingPlaceholder is true, text is the placeholder boilerplate ("Click here to enter text"), not entered content. Check this flag before treating text as real data.
Setting text content
setContentControlContent fills a richText or plainText control with a string (or BlockContent[] headless):
import { setContentControlContent, removeContentControl } from "@docx-editor.dev/core/headless";
let next = setContentControlContent(doc, { tag: "intro" }, "Filled by template");
// Conditional sections: drop a control, or unwrap it (keep its content)
next = removeContentControl(next, { tag: "optionalClause" });
next = removeContentControl(next, { tag: "wrapper" }, { keepContent: true });All headless mutators are pure: they return a new Document and preserve the control's identity and raw w:sdtPr, so the result still round-trips. By default mutators affect the first match; when a tag repeats, enumerate with findContentControls and disambiguate by id (or pass { all: true } to act on every match).
Safety rules (each overridable with { force: true }):
- A content-locked control throws
ContentControlLockedErroron edit; a deletion-locked one throws on remove. - A typed control (dropdown, date, checkbox, picture, group) throws
ContentControlTypeErroron free-text replacement. Use the typed setter below. - A data-bound control (
w:dataBinding) throwsContentControlBoundError: its content is driven by the Custom XML store, so a direct write would not persist in Word. - An unmatched filter throws
ContentControlNotFoundError.
Setting typed values (dropdown, checkbox, date)
setContentControlValue updates both the visible content and the structured w:sdtPr state:
import { setContentControlValue } from "@docx-editor.dev/core/headless";
setContentControlValue(doc, { tag: "status" }, { kind: "dropdown", value: "2" });
setContentControlValue(doc, { tag: "agree" }, { kind: "checkbox", checked: true });
setContentControlValue(doc, { tag: "effective" }, { kind: "date", date: "2026-06-01" });A dropdown value must match one of the control's list items (by value or display text). A date is ISO yyyy-mm-dd and is rendered with the control's w:dateFormat. comboBox controls are pick-only today; typing a value outside the list is not supported.
In the editor, typed controls also get an interactive trigger at the top-right of their box: it toggles the checkbox, opens the dropdown's item menu, or opens a date picker, each as a normal undoable edit. No wiring required, in both the React and Vue adapters.
Creating controls
createContentControl wraps a text span in a new control — the step that turns a placeholder in a template into a fillable, tagged region (including inside a table cell). It is pure (returns a new Document) and returns the created control:
import { createContentControl } from "@docx-editor.dev/core/headless";
// Wrap "[CUSTOMER]" in the paragraph with this Word w14:paraId.
const { doc: d1, control } = createContentControl(
doc,
{ paraId: "0A1B2C3D", text: "[CUSTOMER]" },
{ tag: "customer", alias: "Customer", sdtType: "plainText" }
);
// Typed controls are created ready to fill — set the value afterwards.
const { doc: d2 } = createContentControl(
doc,
{ paraId: "0A1B2C3D", text: "[DATE]" },
{ tag: "effective", sdtType: "date", dateFormat: "MMMM d, yyyy" }
);The w:id is auto-assigned (pass props.id to set it yourself), and the result round-trips immediately, so findContentControl(doc, { tag }) resolves it and a typed control accepts setContentControlValue. createContentControl throws ContentControlCreateError if the text isn't found or the span can't be cleanly wrapped (for example, it overlaps an existing control).
Repeating sections
Repeating-section controls (w15:repeatingSection) round-trip verbatim, and two helpers manage their items:
import { addRepeatingSectionItem, removeRepeatingSectionItem } from "@docx-editor.dev/core/headless";
// Clone an item (with fresh ids); afterIndex picks the insertion point.
let next = addRepeatingSectionItem(doc, { tag: "lineItems" }, { afterIndex: 0 });
// Drop one item by index.
next = removeRepeatingSectionItem(next, { tag: "lineItems" }, 2);Unwrapping a repeating-section control is refused, since it would orphan the w15 structure.
In the live editor
The same operations exist on DocxEditorRef, running against the editor so writes are normal undoable edits:
const ref = useRef<DocxEditorRef>(null);
ref.current?.getContentControls({ type: "dropDownList" }); // PMContentControl[]
ref.current?.scrollToContentControl({ tag: "intro" });
ref.current?.setContentControlContent({ tag: "intro" }, "Filled"); // boolean
ref.current?.setContentControlValue({ tag: "agree" }, { kind: "checkbox", checked: true });
ref.current?.removeContentControl({ tag: "optionalClause" });These return false when no control matches; locked and typed controls throw under the same rules as headless unless { force: true } is passed. The ref's setContentControlContent takes a string (newlines become paragraphs).
Headless document generation
A complete fill-and-export pipeline in Node:
import { readFile, writeFile } from "node:fs/promises";
import {
parseDocx,
setContentControlContent,
setContentControlValue,
removeContentControl,
repackDocx,
} from "@docx-editor.dev/core/headless";
const buffer = await readFile("contract-template.docx");
let doc = await parseDocx(buffer);
doc = setContentControlContent(doc, { tag: "customerName" }, "Acme GmbH");
doc = setContentControlValue(doc, { tag: "effective" }, { kind: "date", date: "2026-07-01" });
doc = removeContentControl(doc, { tag: "betaClause" }); // condition not met
const out = await repackDocx(doc);
await writeFile("contract-acme.docx", Buffer.from(out));Headers and footers
By default the API searches the main document body. Pass includeHeadersFooters: true to also search headers and footers:
findContentControls(doc, { type: 'checkbox' }, { includeHeadersFooters: true });
setContentControlContent(doc, { tag: 'supplier' }, 'ACME Ltd', { includeHeadersFooters: true });includeHeadersFooters needs a full Document (a bare DocumentBody has no header/footer parts), and a result's location tells you which part it lives in. When filling a control, the value inherits the surrounding formatting so it matches the text it replaces.
The live-editor DocxEditorRef methods (getContentControls / setContentControlContent / removeContentControl) cover the body only — they take no includeHeadersFooters option, so header and footer controls are addressable from the headless API.
Filling every match
By default the mutators (setContentControlContent, setContentControlValue, removeContentControl) act on the first control matching the filter. Pass { all: true } to act on every match instead — for one logical value that recurs under a shared tag (a name in the body, a running header, and several table cells):
setContentControlContent(doc, { tag: 'customerName' }, 'Acme GmbH', { all: true, includeHeadersFooters: true });
removeContentControl(doc, {}, { all: true, keepContent: true }); // flatten: unwrap every control{ all: true } is atomic — if any matched control is refused by a lock, type, or data-binding guard, nothing is written unless you also pass { force: true }.
Current limits
- A control that wraps a whole table cell or row isn't supported (inline controls inside a cell are).
- A control inside a hyperlink isn't surfaced.
- Controls inside tracked changes, footnotes, and endnotes are not searched.
dataBindingand repeating sections round-trip but have no live behavior (no bound-value resolution, no automatic repeat expansion).
Next steps
- Headless processing for the full server-side toolkit
- Core headless API for every exported helper
- AI & Agents for filling documents from an LLM
Comments
Add comment threads to DOCX documents: replies, resolve state, the comments sidebar, controlled comment props and callbacks, and programmatic APIs.
Headers & footers
Edit DOCX headers and footers in place, insert PAGE and NUMPAGES fields, use per-section first-page and even-page variants, and add watermarks.