# Build a custom toolbar

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



Configure the [built-in toolbar](/editor/built-in-ui/configure-the-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](/editor/custom-ui/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 [#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**.

> **Live example: scale one control into a custom toolbar**
>
> Select text in the real DOCX, then use the application-owned Bold, font-family, and font-size controls. The toolbar reads `active`, `value`, and `enabled` from the corresponding command handles. Formatting one sentence and extending the selection into plain text makes the font and size pickers show `Mixed`. Each action reports the result from `executeAsync()`.


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 [#build-the-same-toolbar]

Continue with the `/sample.docx` project from [Build your first custom control](/editor/custom-ui/controller-setup). This
time, set `toolbar: false` to remove the remaining built-in toolbar.

## Add the toolbar surface [#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.

```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 [#bind-the-commands-and-options]

Replace the first-control Editor code:

**Vanilla — `src/main.ts`**

```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();
});

```

**React — `src/App.tsx`**

```tsx
import { useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { UIConfig } from 'superdoc';
import type { CommandExecutionResult } from 'superdoc/ui';
import {
  SuperDocUIProvider,
  useSetSuperDoc,
  useSuperDocCommand,
  useSuperDocFontOptions,
  useSuperDocFontSizeOptions,
} from 'superdoc/ui/react';
import '@superdoc/react/style.css';

const editorUi = { toolbar: false } satisfies UIConfig;

type PickerOption = { value: string; label: string };

// A uniform value outside the preset list is still one value. Keep it
// selectable so the picker shows what the document has.
function getPickerChoices(options: readonly PickerOption[], commandValue: unknown): PickerOption[] {
  const selected = typeof commandValue === 'string' || typeof commandValue === 'number' ? String(commandValue) : '';
  if (!selected || options.some(({ value }) => value === selected)) return [...options];
  return [{ value: selected, label: selected }, ...options];
}

function getSelectedOptionValue(commandValue: unknown): string {
  return typeof commandValue === 'string' || typeof commandValue === 'number' ? String(commandValue) : '';
}

export default function App() {
  return (
    <SuperDocUIProvider>
      <FormattingToolbar />
      <Editor />
    </SuperDocUIProvider>
  );
}

function FormattingToolbar() {
  const bold = useSuperDocCommand('bold');
  const fontFamily = useSuperDocCommand('font-family');
  const fontSize = useSuperDocCommand('font-size');
  const fontOptions = useSuperDocFontOptions();
  const sizeOptions = useSuperDocFontSizeOptions();
  const [pending, setPending] = useState(false);
  const [status, setStatus] = useState('Select text to format it.');

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

  // 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.
  async function run(action: () => Promise<CommandExecutionResult>, message: string) {
    if (pending) return;
    setPending(true);
    try {
      report(await action(), message);
    } finally {
      setPending(false);
    }
  }

  const familyValue = getSelectedOptionValue(fontFamily.value);
  const sizeValue = getSelectedOptionValue(fontSize.value);
  const familyChoices = getPickerChoices(fontOptions, fontFamily.value);
  const sizeChoices = getPickerChoices(sizeOptions, fontSize.value);

  return (
    <>
      <div aria-label='Formatting controls' role='toolbar'>
        <button
          aria-pressed={bold.active}
          disabled={pending || !bold.enabled}
          onClick={() => void run(() => bold.executeAsync(), bold.active ? 'Bold removed.' : 'Bold applied.')}
          onMouseDown={(event) => event.preventDefault()}
          type='button'
        >
          Bold
        </button>
        <label>
          Font
          <select
            disabled={pending || !fontFamily.enabled}
            onChange={(event) => void run(() => fontFamily.executeAsync(event.target.value), 'Font updated.')}
            value={familyValue}
          >
            <option disabled value=''>
              Mixed
            </option>
            {familyChoices.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
        </label>
        <label>
          Size
          <select
            disabled={pending || !fontSize.enabled}
            onChange={(event) => void run(() => fontSize.executeAsync(event.target.value), 'Font size updated.')}
            value={sizeValue}
          >
            <option disabled value=''>
              Mixed
            </option>
            {sizeChoices.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
        </label>
      </div>
      <output aria-live='polite' role='status'>
        {status}
      </output>
    </>
  );
}

function Editor() {
  const setSuperDoc = useSetSuperDoc();

  return (
    <SuperDocEditor
      document='/sample.docx'
      onContentError={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onException={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onReady={({ superdoc }) => setSuperDoc(superdoc)}
      ui={editorUi}
    />
  );
}

```


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 [#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 [#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](/editor/custom-ui/zoom-and-document-state) to apply the same loop to
actions that target the document instead of the current selection. Use
[Custom commands](/editor/custom-ui/custom-commands) later for application actions that SuperDoc does not provide.
