> ## Documentation Index
> Fetch the complete documentation index at: https://docs.superdoc.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Content controls

> Build your own UI for Word content controls (SDT fields): turn off the built-in chrome, react to the active control, and anchor your UI with getRect().

Turn off SuperDoc's built-in chrome, listen for the active control, and anchor your own UI over it. The control wrappers and `data-sdt-*` attributes stay in the DOM, so your UI has something to attach to.

## A minimal field chip

```ts theme={null}
import { SuperDoc } from 'superdoc';
import { createSuperDocUI } from 'superdoc/ui';

new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  // Turn off the built-in labels, borders, and hover/selection chrome.
  modules: { contentControls: { chrome: 'none' } },
  onReady: ({ superdoc }) => {
    const ui = createSuperDocUI({ superdoc });

    superdoc.on('content-control:active-change', ({ active }) => {
      if (!active) return chip.hide(); // `chip` is your own element
      const rect = ui.contentControls.getRect({ id: active.id });
      if (rect.success) chip.showAt(rect.rect, active.alias ?? active.tag);
    });
  },
});
```

The event tells you *what* is active; `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.

```css theme={null}
/* A field your app tagged { kind: 'smartField', ... } */
.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] {
  --sd-content-controls-custom-inline-border: 1px solid #1355ff;
  --sd-content-controls-custom-inline-bg: color-mix(in srgb, #1355ff 12%, transparent);
  --sd-content-controls-custom-inline-hover-bg: color-mix(in srgb, #1355ff 20%, transparent);
  --sd-content-controls-custom-inline-radius: 4px;
  --sd-content-controls-custom-inline-padding: 1px 6px;
}

/* A clause your app tagged { kind: 'reusableSection', ... } */
.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] {
  --sd-content-controls-custom-block-border: 1px solid #d6e0ff;
  --sd-content-controls-custom-block-border-left: 4px solid #1355ff; /* accent rail */
  --sd-content-controls-custom-block-bg: color-mix(in srgb, #1355ff 4%, transparent);
  --sd-content-controls-custom-block-radius: 6px;
}
```

`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 with `getRect`, 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:

1. **Define a tag schema.** Give each control a JSON `tag` your app owns - e.g. `{ kind: 'smartField', key }` for inline variables and `{ kind: 'reusableSection', sectionId }` for clauses.
2. **Insert from a palette.** Drop a control at a point with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`, resolving the drop point with `ui.viewport.positionAt({ x, y })`. A clause can wrap its `{ field }` slots as nested inline controls.
3. **Style it.** Set the `--sd-content-controls-custom-*` variables on a `data-sdt-tag` selector (see [Style the controls in place](#style-the-controls-in-place)). The sidebar chips can reuse the same tokens, so palette and document match.
4. **React.** Highlight the active control from `content-control:active-change` / `:click`, and anchor overlays with `getRect` + `ui.viewport.observe`.
5. **Fill by tag.** A form edits a value and fans it to every occurrence: `editor.doc.contentControls.selectByTag({ tag })`, then `text.setValue` per occurrence.
6. **Govern with locks.** Keep controls `contentLocked` so users can't edit them, and have the form unlock → write → relock (see [Lock modes](/document-api/features/content-controls#lock-modes)). For a field nested in a locked clause, unlock the parent for the write.

## See also

* [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export.
* [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option.
* [Document API: content controls](/document-api/features/content-controls) - read and change controls.
