Custom UI

Custom UI controller setup

Bind a custom control to live SuperDoc command state and execute it safely.

This guide creates one application-owned Bold button over the public superdoc/ui controller. The button follows live selection state, executes the real Editor command, and cleans up with the Editor.

Complete the Editor quickstart first if you have not mounted a v2 Editor before.

Add the HTML surface

Create the button and an editor container:

<button id="bold" type="button" disabled aria-pressed="false">Bold</button>
<div id="editor"></div>

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

Copy the tracked-changes fixture to your app's public directory as contract.docx, or update the example to use another DOCX URL.

Bind the controller after readiness

Use this as src/main.ts:

import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';

const boldButton = document.querySelector<HTMLButtonElement>('#bold');
if (!boldButton) throw new Error('The Bold button is missing.');

let stopObserving: (() => void) | null = null;
let removeClickHandler: (() => void) | null = null;

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  // This page's Bold button replaces one toolbar control, so the built-in
  // toolbar is the only surface turned off.
  ui: { toolbar: false },
  onReady: ({ superdoc: readySuperDoc }) => {
    const bold = readySuperDoc.ui.commands.get('bold');

    const render = (state: ReturnType<typeof bold.getState>) => {
      boldButton.disabled = !state.enabled;
      boldButton.setAttribute('aria-pressed', String(state.active));
      boldButton.title = state.reason ?? 'Toggle bold';
    };

    const onBoldClick = async () => {
      const result = await bold.executeAsync();
      if (result === false) console.warn('Bold is not available for the current selection.');
    };

    render(bold.getState());
    stopObserving = bold.observe(render);
    boldButton.addEventListener('click', onBoldClick);
    removeClickHandler = () => boldButton.removeEventListener('click', onBoldClick);
  },
});

window.addEventListener('beforeunload', () => {
  stopObserving?.();
  removeClickHandler?.();
  // Tears the controller down too. Never call `superdoc.ui.destroy()` yourself.
  superdoc.destroy();
});

Every SuperDoc instance owns one controller at superdoc.ui. Read it as many times as you like; you always get the same object, and so does the built-in toolbar if you render one. Reading it before the document is ready is safe, but the command handle only reports real state once the Editor is ready, which is why the binding happens in onReady.

The instance owns the controller's lifecycle. superdoc.destroy() tears it down, and superdoc.ui is typed as the borrowed handle BorrowedSuperDocUI, which has no destroy() at all. Calling it is a compile error rather than a rule to remember, because doing so would freeze command state for every other part of your interface still reading it.

Remove the built-in surfaces

This guide's Bold button replaces one toolbar control, so ui: { toolbar: false } is all it needs. As your interface takes over more of the document experience, each built-in surface has its own switch:

  • ui: { toolbar: false } renders no built-in toolbar.
  • ui: { comments: false } removes the built-in comments interface: the sidebar, floating threads, and comment dialog.
  • ui: { contextMenu: false } removes the built-in right-click and slash menus.
  • ui: { linkPopover: false } suppresses the popover that opens when a reader clicks a link.

Take only the switches whose surface you actually replace. They are independent, and each removes one presentation and nothing else, so turning off a surface you have not rebuilt leaves the reader with less than SuperDoc would have given them. The custom comments UI shows the comments switch used the right way round: it mounts with ui: { comments: false } because it renders its own panel, and leaves everything else in place.

When your application owns the entire interface, ui: false turns off every built-in surface at once. It does not reduce what the editor can do: editing, the Document API, interaction, surfaces, and superdoc.ui all stay exactly as they were.

These are rendering switches, not permissions. ui: { comments: false } hides the built-in comments interface but does not stop your own panel from creating comments; interaction: { comments: { readOnly: true } } is what refuses the write.

The underlying capabilities stay available through superdoc.ui. Comment threads in the DOCX are still parsed and readable through superdoc.ui.comments with the surface off, and resolve and reopen still commit. See built-in comments for the full distinction.

Follow command state

getState() provides the initial state. observe() keeps the button synchronized as the selection and document change:

  • enabled controls whether the action can run.
  • active indicates whether the current selection is bold.
  • reason explains why a known command is disabled.

This is more reliable than deriving state from rendered DOM nodes. The document canvas is presentation; the controller is the public UI state boundary.

Execute and inspect the result

The click handler calls executeAsync(). A false result means the command did not run. A mutation receipt should be inspected before starting dependent work.

For a complete toolbar, repeat this pattern with command IDs exposed by superdoc/ui, but keep the first implementation focused on controls the workflow actually needs.

Build the next control with the same state-and-command pattern, or return to Choose your editor interface before expanding the toolbar.

On this page