Custom UI

Build your first custom control

Move Bold into your application while SuperDoc renders the document and remaining toolbar.

Start from the Editor quickstart. Remove Bold from SuperDoc's toolbar, then render a Bold button in your application. This is the smallest step from configuring the built-in UI to owning part of it.

Try the ownership handoff

Select text in the document. Use Bold under Your application, then use Italic under SuperDoc UI. Both controls should follow the same selection.

Only the visible Bold control changed owner. SuperDoc still renders the DOCX canvas and the remaining toolbar.

Build the same handoff

Choose Vanilla or React below. Your choice stays active across the examples.

If you chose React in the quickstart, add superdoc as a direct dependency. The custom UI hooks come from its superdoc/ui/react entry point:

pnpm add superdoc

1. Move Bold into your application

Vanilla needs a button next to its existing Editor container. React renders the button inside the component in the next step and keeps the quickstart's index.html. Vanilla also needs a mount for the remaining built-in toolbar. If you use Vanilla, replace index.html with:

<div id="toolbar"></div>
<div aria-label="Document controls" role="toolbar">
  <button id="bold" type="button" disabled aria-pressed="false">Bold</button>
</div>
<output id="status" aria-live="polite" role="status">Select text to format it.</output>
<div id="editor"></div>

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

2. Connect Bold to the Editor

Replace the quickstart Editor code:

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

const boldButton = document.querySelector<HTMLButtonElement>('#bold');
const status = document.querySelector<HTMLOutputElement>('#status');
if (!boldButton || !status) throw new Error('The custom controls are missing.');

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

const editorUi = {
  toolbar: {
    container: '#toolbar',
    excludeItems: ['bold'],
  },
} satisfies UIConfig;

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

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

    const onBoldClick = async () => {
      if (pending) return;
      const message = bold.getState().active ? 'Bold removed.' : 'Bold applied.';
      pending = true;
      render(bold.getState());
      try {
        const result = await bold.executeAsync();
        const applied = result === true || (typeof result === 'object' && result.success);
        status.textContent = applied ? message : 'Bold was not changed.';
      } finally {
        pending = false;
        render(bold.getState());
      }
    };

    const preserveSelection = (event: MouseEvent) => event.preventDefault();
    render(bold.getState());
    stopObserving = bold.observe(render);
    boldButton.addEventListener('mousedown', preserveSelection);
    boldButton.addEventListener('click', onBoldClick);
    removeHandlers = () => {
      boldButton.removeEventListener('mousedown', preserveSelection);
      boldButton.removeEventListener('click', onBoldClick);
    };
  },
  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', () => {
  stopObserving?.();
  removeHandlers?.();
  superdoc.destroy();
});

Both versions make the same ownership change:

  • toolbar.excludeItems removes the built-in Bold button. The Bold command remains available.
  • The application-owned button reads that command's state and runs it through superdoc.ui.
  • The Editor owns the controller. Your application releases only the subscription and event handlers it created.

The mousedown handler keeps the document selection active when the custom button receives a mouse click. Keyboard activation continues to use the button's normal focus and click behavior. Selection and position explains that pattern for menus, popovers, and other application UI.

3. Run the control

Run the project:

pnpm dev

Select text in the DOCX. Bold becomes enabled and reflects whether the selection is bold. Choose it and confirm that the status reports whether bold was applied or removed. The remaining built-in toolbar controls should still render.

The runnable custom UI example also verifies the exported DOCX.

Continue with Commands and state to apply the same state and execution pattern to other controls.

On this page