Custom UI

Build a custom toolbar

Scale one working custom control into an application-owned toolbar that follows the current selection.

Configure the built-in toolbar when you only need to choose which controls it shows. Continue here when your application needs to own the toolbar's markup, layout, accessibility, or feedback.

The Bold button from Commands and state already observes state, renders it, runs a command, and handles the result. Keep that loop. Add font-family and font-size pickers to turn it into a small toolbar.

Try the loop at toolbar scale

Select the first sentence below. Apply Bold, then choose a font and size. Reselect that sentence to see all three controls recover its formatting. Extend that formatted selection into the next plain sentence and the pickers show Mixed.

The toolbar demonstrates three reusable control shapes:

  • A toggle renders active through aria-pressed.
  • A picker renders value, or Mixed when the selection has no single value.
  • Every control renders enabled through its disabled state and reports what executeAsync() returns.

SuperDoc owns the document behavior and publishes command state. Your application owns the toolbar element, control labels, focus behavior, and action feedback.

Build the same toolbar

Continue with the /sample.docx project from Build your first custom control. This time, set toolbar: false to remove the remaining built-in toolbar.

Add the toolbar surface

For Vanilla, replace index.html with the toolbar and Editor mounts below. React renders the controls in App.tsx and keeps the quickstart HTML.

<div aria-label="Formatting controls" role="toolbar">
  <button id="bold" type="button" aria-pressed="false" disabled>Bold</button>
  <label>
    Font
    <select id="font-family" disabled>
      <option value="">Mixed</option>
    </select>
  </label>
  <label>
    Size
    <select id="font-size" disabled>
      <option value="">Mixed</option>
    </select>
  </label>
</div>

<p id="formatting-status" role="status">Opening document...</p>
<div id="editor" style="height: 70vh"></div>

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

Bind the commands and options

Replace the first-control Editor code:

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

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing custom toolbar element: ${selector}`);
  return element;
}

const boldButton = getElement<HTMLButtonElement>('#bold');
const fontFamilySelect = getElement<HTMLSelectElement>('#font-family');
const fontSizeSelect = getElement<HTMLSelectElement>('#font-size');
const status = getElement<HTMLParagraphElement>('#formatting-status');

const editorUi = { toolbar: false } satisfies UIConfig;
let stopBindings: (() => void) | null = null;
let pending = false;

function syncOptions(
  select: HTMLSelectElement,
  options: readonly { value: string; label: string }[],
  selectedValue: unknown,
) {
  const selected = typeof selectedValue === 'string' || typeof selectedValue === 'number' ? String(selectedValue) : '';
  // `Mixed` describes a selection with no single value. It is not a command
  // payload, so it stays visible but cannot be chosen.
  const mixed = document.createElement('option');
  mixed.value = '';
  mixed.textContent = 'Mixed';
  mixed.disabled = true;
  // A uniform value outside the preset list is still one value. Keep it
  // selectable so the picker shows what the document has.
  const choices =
    selected && !options.some(({ value }) => value === selected)
      ? [{ value: selected, label: selected }, ...options]
      : options;
  select.replaceChildren(
    mixed,
    ...choices.map(({ value, label }) => {
      const option = document.createElement('option');
      option.value = value;
      option.textContent = label;
      return option;
    }),
  );
  select.value = selected;
}

function report(result: CommandExecutionResult, message: string) {
  if (result === false) {
    status.textContent = 'The formatting action is unavailable.';
  } else if (typeof result === 'object' && !result.success) {
    status.textContent = result.failure.message;
  } else {
    status.textContent = message;
  }
}

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/sample.docx',
  ui: editorUi,
  onReady: ({ superdoc: readySuperDoc }) => {
    stopBindings?.();

    const { ui } = readySuperDoc;
    const bold = ui.commands.get('bold');
    const fontFamily = ui.commands.get('font-family');
    const fontSize = ui.commands.get('font-size');

    const render = () => {
      const fonts = ui.fonts.getSnapshot();
      const boldState = bold.getState();
      const familyState = fontFamily.getState();
      const sizeState = fontSize.getState();

      boldButton.disabled = pending || !boldState.enabled;
      boldButton.setAttribute('aria-pressed', String(boldState.active));
      syncOptions(fontFamilySelect, fonts.options, familyState.value);
      syncOptions(fontSizeSelect, fonts.sizeOptions, sizeState.value);
      fontFamilySelect.disabled = pending || !familyState.enabled;
      fontSizeSelect.disabled = pending || !sizeState.enabled;
    };

    // One action at a time. A second click before the first settles would
    // dispatch a second toggle, and overlapping picker changes could report
    // their results out of order.
    const run = async (action: () => Promise<CommandExecutionResult>, message: string) => {
      if (pending) return;
      pending = true;
      render();
      try {
        report(await action(), message);
      } finally {
        pending = false;
        render();
      }
    };

    const preserveSelection = (event: MouseEvent) => event.preventDefault();
    const toggleBold = () => run(() => bold.executeAsync(), bold.getState().active ? 'Bold removed.' : 'Bold applied.');
    // Read the chosen value before `run()` rerenders the select, which resets
    // it to the command's current value.
    const setFontFamily = () => {
      const value = fontFamilySelect.value;
      return run(() => fontFamily.executeAsync(value), 'Font updated.');
    };
    const setFontSize = () => {
      const value = fontSizeSelect.value;
      return run(() => fontSize.executeAsync(value), 'Font size updated.');
    };

    const stopObservers = [
      ui.fonts.observe(render),
      bold.observe(render),
      fontFamily.observe(render),
      fontSize.observe(render),
    ];

    boldButton.addEventListener('mousedown', preserveSelection);
    boldButton.addEventListener('click', toggleBold);
    fontFamilySelect.addEventListener('change', setFontFamily);
    fontSizeSelect.addEventListener('change', setFontSize);

    stopBindings = () => {
      for (const stop of stopObservers) stop();
      boldButton.removeEventListener('mousedown', preserveSelection);
      boldButton.removeEventListener('click', toggleBold);
      fontFamilySelect.removeEventListener('change', setFontFamily);
      fontSizeSelect.removeEventListener('change', setFontSize);
    };

    render();
    status.textContent = 'Select text to format it.';
  },
  onContentError: ({ error }) => {
    status.textContent = 'The document could not be opened.';
    console.error(error);
  },
  onException: ({ error }) => {
    status.textContent = 'The document could not be opened.';
    console.error(error);
  },
});

window.addEventListener('beforeunload', () => {
  stopBindings?.();
  superdoc.destroy();
});

Both versions scale the same control loop:

  • Get one command handle for Bold, font family, and font size.
  • Read ui.fonts for the values each picker can send.
  • Render each command's current enabled, active, or value state.
  • Run the command and report whether it finished.

The React hook exposes state and execution on the same object. Vanilla uses the equivalent command handle returned by ui.commands.get(id).

Represent picker values

Pass each option's value to its command. A font option's previewFamily is only a CSS value for previewing that choice in your interface.

A mixed selection has no single command value. Show a non-selectable Mixed option until the selection resolves to one font or size. Continue to let the command state drive the control; do not inspect the rendered document DOM.

Verify the toolbar

Run the project. Select text in /sample.docx and change all three controls. Move the selection away, then select the formatted text again. Bold, font, and size should recover the values you applied. Extend the selection into neighboring text that kept its original formatting. Both pickers should show Mixed.

Next, build application document controls to apply the same loop to actions that target the document instead of the current selection. Use Custom commands later for application actions that SuperDoc does not provide.

On this page