data-sdt-* attributes stay in the DOM, so your UI has something to attach to.
A minimal field chip
getRect tells you where to draw. active is an SdtRef with id, tag, alias, controlType, and scope.
Style the controls in place
Turning off chrome erases the built-in look, including hover and selection. To paint your own field and clause look, set--sd-content-controls-custom-* variables on the painted wrapper. Target it by your own data-sdt-* attributes. No !important, and no need to touch SuperDoc’s internal state classes: the painter applies your variables across rest, hover, and selected, so the box stays stable and you never write .ProseMirror-selectednode or hover rules yourself.
border is a full CSS shorthand; border-left is an optional accent rail for block clauses. The background variables cascade, so set only what changes: -hover-bg defaults to -bg, and -selected-bg defaults to -hover-bg.
This is the path for chrome: 'none'. To theme the built-in chrome instead (chrome: 'default'), use the --sd-content-controls-* variables (without custom).
Pick the right surface
| Goal | API |
|---|---|
| Active control (enter, switch, leave) | superdoc.on('content-control:active-change') |
| Click inside a control | superdoc.on('content-control:click') |
| Full live list and active stack | ui.contentControls.observe() / getSnapshot() |
| Read one control | ui.contentControls.get({ id }) |
| Position your UI | ui.contentControls.getRect({ id }) |
| Scroll a control into view | ui.contentControls.scrollIntoView({ id }) |
| Scroll to it and put the cursor in | ui.contentControls.focus({ id }) |
| Re-anchor your UI when the page moves | ui.viewport.observe(() => ...) |
| Hover and right-click hit-testing | ui.viewport.entityAt() / contextAt() |
| Change content, tags, or locks | editor.doc.contentControls.* |
active is the innermost control. For nested controls (an inline field inside a block clause), activePath carries the full stack, innermost first, so you don’t also need observe() just to read the nesting.
scrollIntoView resolves the control’s position from the document, so it works even when the control is on a page that hasn’t rendered yet (the page mounts, then scrolls). It scrolls only - it does not move the cursor into the control. focus does both: scrolls to the control and places the caret inside so the user can start typing. focus is selection, not editing - it does not bypass lock or document-mode rules, so a locked or read-only control can be focused for inspection but edits are still blocked.
ui.viewport.observe is the single signal for “your getRect() coordinates may be stale, re-query”: it fires (coalesced, once per frame) on scroll, resize, zoom, and layout reflow, so an overlay anchored with getRect stays glued without hand-wiring those events yourself.
How the model works
You build your UI over the control, not inside it. SuperDoc owns how the control’s content is painted in the document; you turn off its built-in chrome and draw your own (chips, badges, panels) anchored withgetRect, react with the events, and change content through editor.doc.contentControls.*. Custom field types are expressed as a tag - for example { kind: 'smartField', key: 'party_name' }, interpreted by your own UI - the underlying control stays a standard Word SDT so it round-trips to .docx.
Build a custom field system
Putting it together into a fillable template, the way the contract-templates demo does:- Define a tag schema. Give each control a JSON
tagyour app owns - e.g.{ kind: 'smartField', key }for inline variables and{ kind: 'reusableSection', sectionId }for clauses. - Insert from a palette. Drop a control at a point with
editor.doc.create.contentControl({ kind, at, content, tag, lockMode }), resolving the drop point withui.viewport.positionAt({ x, y }). A clause can wrap its{ field }slots as nested inline controls. - Style it. Set the
--sd-content-controls-custom-*variables on adata-sdt-tagselector (see Style the controls in place). The sidebar chips can reuse the same tokens, so palette and document match. - React. Highlight the active control from
content-control:active-change/:click, and anchor overlays withgetRect+ui.viewport.observe. - Fill by tag. A form edits a value and fans it to every occurrence:
editor.doc.contentControls.selectByTag({ tag }), thentext.setValueper occurrence. - Govern with locks. Keep controls
contentLockedso users can’t edit them, and have the form unlock → write → relock (see Lock modes). For a field nested in a locked clause, unlock the parent for the write.
See also
- Contract templates demo - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export.
- Configuration - the
modules.contentControls.chromeoption. - Document API: content controls - read and change controls.

