# Build formatting controls

> Connect custom font, size, paragraph-style, and document-mode controls to the active Editor state.



Use the custom UI controller when your application owns the formatting toolbar. The controller provides picker options, active values, disabled reasons, and commands that follow the current selection.

Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This example expects `/contract.docx` to be available from your application.

## Add the controls [#add-the-controls]

Create font, size, paragraph-style, and document-mode selectors with a status message:

```html
<div aria-label="Formatting controls">
  <label>Font <select id="font-family"></select></label>
  <label>Size <select id="font-size"></select></label>
  <label>Paragraph style <select id="paragraph-style"></select></label>
  <label>
    Mode
    <select id="document-mode">
      <option value="editing">Editing</option>
      <option value="suggesting">Suggesting</option>
      <option value="viewing">Viewing</option>
    </select>
  </label>
  <p id="formatting-status" role="status"></p>
</div>

<div id="editor" style="height: 70vh"></div>

<script type="module" src="/src/main.ts"></script>

```

## Bind options, state, and commands [#bind-options-state-and-commands]

Read the Editor's controller, `superdoc.ui`, after the Editor is ready:

```ts
import { SuperDoc } from 'superdoc';
import type { CommandExecutionResult } from 'superdoc/ui';
import 'superdoc/style.css';

const fontFamilySelect = document.querySelector<HTMLSelectElement>('#font-family');
const fontSizeSelect = document.querySelector<HTMLSelectElement>('#font-size');
const paragraphStyleSelect = document.querySelector<HTMLSelectElement>('#paragraph-style');
const documentModeSelect = document.querySelector<HTMLSelectElement>('#document-mode');
const formattingStatus = document.querySelector<HTMLParagraphElement>('#formatting-status');

if (!fontFamilySelect || !fontSizeSelect || !paragraphStyleSelect || !documentModeSelect || !formattingStatus) {
  throw new Error('The formatting controls are incomplete.');
}

let stopObservers: Array<() => void> = [];
let removeHandlers: (() => void) | null = null;

const replaceOptions = (
  select: HTMLSelectElement,
  options: readonly { value: string; label: string }[],
  selected: string | null,
) => {
  select.replaceChildren(
    ...options.map(({ value, label }) => {
      const option = document.createElement('option');
      option.value = value;
      option.textContent = label;
      return option;
    }),
  );
  if (selected && options.some((option) => option.value === selected)) select.value = selected;
};

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: ({ superdoc: readySuperDoc }) => {
    const ui = readySuperDoc.ui;
    const fontFamily = ui.commands.get('font-family');
    const fontSize = ui.commands.get('font-size');
    const paragraphStyle = ui.commands.get('linked-style');
    const documentMode = ui.commands.get('document-mode');

    const render = () => {
      const fonts = ui.fonts.getSnapshot();
      const styles = ui.styles.getSnapshot();
      const document = ui.document.getSnapshot();
      const familyState = fontFamily.getState();
      const sizeState = fontSize.getState();
      const styleState = paragraphStyle.getState();

      replaceOptions(
        fontFamilySelect,
        fonts.options,
        typeof familyState.value === 'string' ? familyState.value : null,
      );
      replaceOptions(fontSizeSelect, fonts.sizeOptions, typeof sizeState.value === 'string' ? sizeState.value : null);
      replaceOptions(
        paragraphStyleSelect,
        styles.quickGallery.map((style) => ({ value: style.id, label: style.name })),
        styles.activeParagraphStyleId,
      );

      fontFamilySelect.disabled = !familyState.enabled;
      fontSizeSelect.disabled = !sizeState.enabled;
      paragraphStyleSelect.disabled = !styleState.enabled || styles.quickGallery.length === 0;
      documentModeSelect.value = document.mode ?? 'editing';
    };

    const report = (result: CommandExecutionResult) => {
      if (result === false) {
        formattingStatus.textContent = 'The formatting action is unavailable.';
        return;
      }
      if (typeof result === 'object' && !result.success) {
        formattingStatus.textContent = result.failure.message;
        return;
      }
      formattingStatus.textContent = 'Formatting updated.';
    };

    const setFontFamily = async () => report(await fontFamily.executeAsync(fontFamilySelect.value));
    const setFontSize = async () => report(await fontSize.executeAsync(fontSizeSelect.value));
    const setParagraphStyle = async () => report(await paragraphStyle.executeAsync(paragraphStyleSelect.value));
    const setDocumentMode = async () => report(await documentMode.executeAsync(documentModeSelect.value));

    stopObservers = [
      ui.fonts.observe(render),
      ui.styles.observe(render),
      ui.document.observe(render),
      fontFamily.observe(render),
      fontSize.observe(render),
      paragraphStyle.observe(render),
    ];

    fontFamilySelect.addEventListener('change', setFontFamily);
    fontSizeSelect.addEventListener('change', setFontSize);
    paragraphStyleSelect.addEventListener('change', setParagraphStyle);
    documentModeSelect.addEventListener('change', setDocumentMode);

    removeHandlers = () => {
      fontFamilySelect.removeEventListener('change', setFontFamily);
      fontSizeSelect.removeEventListener('change', setFontSize);
      paragraphStyleSelect.removeEventListener('change', setParagraphStyle);
      documentModeSelect.removeEventListener('change', setDocumentMode);
    };
  },
});

window.addEventListener('beforeunload', () => {
  for (const stop of stopObservers) stop();
  removeHandlers?.();
  superdoc.destroy();
});

```

Each surface owns one part of the interface:

* `ui.fonts` provides document-aware family options and supported size choices.
* `ui.styles` provides the Word quick gallery and active paragraph style.
* `ui.document` reports the current document mode.
* `ui.commands` applies the selected value and reports whether the action can run.

## Preserve document intent [#preserve-document-intent]

Pass the font option's `value` to `font-family`. The value is the logical family written to the DOCX. `previewFamily` is only for rendering the option in your application's picker.

Pass the size option's `value` to `font-size`. The controller normalizes the public picker value before calling the formatting operation.

Paragraph styles use stable style IDs. Show the catalogue's human-readable `name`, but execute `linked-style` with its `id`. Do not reproduce a style by copying its current CSS properties. Applying the style ID preserves the document's style relationship.

## Follow selection and mode changes [#follow-selection-and-mode-changes]

Observe the font, style, document, and command surfaces. A mixed selection can have no single active paragraph style. A locked content control or viewing mode can disable formatting even when the option remains visible.

Use `executeAsync()` before saving or starting dependent work. Inspect the returned result instead of assuming that a change event mutated the document.

Changing document mode updates the posture of the whole Editor. It is not an authorization boundary. Read [Document modes](/editor/document-modes) before exposing mode changes to users.

Verify the workflow by selecting text, changing its font and size, applying a paragraph style, and exporting the DOCX. Reopen it and confirm that the formatting and style ID remain intact.
