Custom UI

React custom UI setup

Bind React controls to the same public SuperDoc UI controller.

superdoc/ui/react provides a React provider and hooks over the framework-neutral controller. Use it when React should render your toolbar or panels while SuperDoc continues to own the document canvas and editing behavior.

This page builds a hybrid interface: React renders the toolbar, and the remaining built-in surfaces stay at their defaults because the example passes no ui. Add ui: false when the application should render every surface, or turn off individual keys as you replace them. Choose an interface covers the three modes.

Complete Custom UI controller setup first if the controller lifecycle is new to you.

Install the public package

Use the same superdoc package for the Editor, controller, and React binding:

pnpm add superdoc@2 react react-dom

The superdoc/ui/react subpath is part of superdoc. Do not install or import the removed v1 headless-toolbar packages.

Bind the Editor to the provider

This example loads contract.docx automatically and adds four application-owned controls: undo, bold, document mode, and fit-to-width.

import { useEffect, useRef } from 'react';
import { SuperDoc } from 'superdoc';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocCommand, useSuperDocUI } from 'superdoc/ui/react';
import 'superdoc/style.css';

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

function CustomToolbar() {
  return (
    <div aria-label='Document controls' role='toolbar'>
      <CommandButton id='undo' label='Undo' />
      <CommandButton id='bold' label='Bold' toggle />
      <DocumentModeSelect />
      <CommandButton id='zoom-fit-width' label='Fit width' />
    </div>
  );
}

function CommandButton({ id, label, toggle = false }: { id: string; label: string; toggle?: boolean }) {
  const ui = useSuperDocUI();
  const state = useSuperDocCommand(id);

  return (
    <button
      aria-pressed={toggle ? state.active : undefined}
      disabled={!state.enabled}
      onClick={() => void ui?.commands.executeAsync(id)}
      title={state.reason ?? label}
      type='button'
    >
      {label}
    </button>
  );
}

function DocumentModeSelect() {
  const ui = useSuperDocUI();
  const state = useSuperDocCommand('document-mode');
  const mode = typeof state.value === 'string' ? state.value : 'editing';

  return (
    <select
      aria-label='Document mode'
      disabled={!state.enabled}
      onChange={(event) => void ui?.commands.executeAsync('document-mode', event.currentTarget.value)}
      value={mode}
    >
      <option value='editing'>Editing</option>
      <option value='suggesting'>Suggesting</option>
      <option value='viewing'>Viewing</option>
    </select>
  );
}

function Editor() {
  const setSuperDoc = useSetSuperDoc();
  const editorRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const superdoc = new SuperDoc({
      selector: editorRef.current,
      document: '/contract.docx',
      onReady: ({ superdoc: readySuperDoc }) => setSuperDoc(readySuperDoc),
    });

    return () => superdoc.destroy();
  }, [setSuperDoc]);

  return <div ref={editorRef} style={{ height: '70vh' }} />;
}

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

SuperDocUIProvider publishes the bound Editor's own controller, superdoc.ui. For a real SuperDoc it does not create one. useSetSuperDoc() returns the stable callback that binds the ready SuperDoc instance.

The React component still owns the Editor instance. Destroy it in the effect cleanup. The provider does not destroy an Editor-owned controller: superdoc.destroy() does that, so unmounting the provider or binding a different Editor leaves the controller intact for anything else reading it.

Read command state through hooks

useSuperDocCommand(id) subscribes the component to one command. Before the Editor is ready, it returns a safe disabled state. After binding, it updates when the selection, document mode, or command availability changes.

Use the returned values directly:

  • enabled controls whether the action can run.
  • active drives toggle state such as Bold.
  • value carries the current document mode or zoom value when the command provides one.
  • reason explains why a known command cannot run.

Do not mirror these values into another React state object. The hook is already the reactive source of truth.

Keep one controller per Editor

Do not call createSuperDocUI() inside a component that already uses SuperDocUIProvider. That factory builds a second, independently owned controller; the provider publishes the Editor's own superdoc.ui when useSetSuperDoc() binds it.

You cannot call ui.destroy() from a component either: the hooks return the borrowed handle, which omits the method. The controller belongs to the Editor, and destroying it would leave the built-in toolbar and every other consumer of that instance reading dead state.

Place controls that use the hooks inside the provider. A hook outside the provider throws an explicit error instead of silently creating an unrelated controller.

Verify the result by selecting text in the DOCX. Bold should enable and reflect the current formatting. Undo should follow document history. Mode and fit-to-width should control the same mounted Editor.

Continue with Custom UI overview, or compare this approach with the built-in toolbar.

On this page