Custom UI

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 first. This example expects /contract.docx to be available from your application.

Add the controls

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

<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

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

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

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

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 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.

On this page