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
activethrougharia-pressed. - A picker renders
value, or Mixed when the selection has no single value. - Every control renders
enabledthrough its disabled state and reports whatexecuteAsync()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:
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.fontsfor the values each picker can send. - Render each command's current
enabled,active, orvaluestate. - 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.