Skip to main content

Documentation Index

Fetch the complete documentation index at: https://superdoc-caio-sd-2929-configurable-toolbar.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

superdoc/ui/react is sugar over a controller. If you are not using React, talk to the controller directly. The hooks just call its methods on your behalf.

When to use this page

You are…Read
Building with ReactReact setup. Skip this page.
Building with Vue, Svelte, Angular, or vanillaThis page.
Building a framework adapterThis page is the contract.
The controller exposes the same surface the React hooks consume. Domain handles, scope-based lifecycle, value-shaped observers. No framework primitives.

Install

pnpm add superdoc

Create the controller

createSuperDocUI({ superdoc }) runs once per editor mount and returns a controller. Hand it the SuperDoc instance.
import { SuperDoc } from 'superdoc';
import { createSuperDocUI } from 'superdoc/ui';
import 'superdoc/style.css';

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
});

const ui = createSuperDocUI({ superdoc });
ui is null-safe to keep around until your app tears down. Call ui.destroy() on unmount.

Bind state with observe

Domain handles emit through observe(snapshot => ...). The listener fires once synchronously with the current snapshot, then again on every change. Returns an unsubscribe.
const off = ui.comments.observe((snapshot) => {
  renderSidebar(snapshot.items);
});

// Later, on tear-down:
off();
The same shape works for every domain: ui.toolbar.observe, ui.selection.observe, ui.trackChanges.observe, ui.document.observe. Per-command state binds the same way: ui.commands.bold.observe(state => ...). A wrapped subscribe(({ snapshot }) => ...) form is also exported. Either works; pick one and stay consistent.

Group teardown with createScope

Without useEffect cleanup, you have to track every unsubscribe yourself. ui.createScope() does that for you.
const scope = ui.createScope();

scope.add(ui.commands.bold.observe((state) => render(state)));
scope.add(ui.comments.observe((snapshot) => renderSidebar(snapshot.items)));
scope.on(window, 'beforeunload', save);

// One call drops everything:
scope.destroy();
ui.destroy() cascades into every live scope, so a typical app needs only one teardown call:
const teardown = () => {
  ui.destroy();
  superdoc.destroy();
};
window.addEventListener('beforeunload', teardown);

Register custom commands

scope.register(...) is the same as ui.commands.register(...) but the scope auto-unregisters on tear-down. The registration is reachable through the same ui.commands.<id> and ui.commands.get(id) paths as built-ins.
scope.register({
  id: 'company.aiRewrite',
  getState: ({ state }) => ({ disabled: state.selection.empty }),
  execute: async ({ editor }) => {
    const target = ui.selection.getSnapshot().selectionTarget;
    if (!target || !editor?.doc?.insert) return false;
    const next = await rewrite(ui.selection.getSnapshot().quotedText);
    return editor.doc.insert({ target, value: next, type: 'text' }).success;
  },
});

Validate config-driven command ids

If your toolbar reads ids from a config file or feature flag, validate at startup. ui.commands.has(id) is the cheap check; ui.commands.require(id) throws on unknown ids at trusted dispatch sites.
import { BUILT_IN_COMMAND_IDS } from 'superdoc/ui';

for (const id of toolbarConfig) {
  if (!ui.commands.has(id)) {
    console.warn(`[toolbar] unknown command: ${id}`);
    continue;
  }
  const handle = ui.commands.require(id);
  scope.add(handle.observe(state => updateButton(id, state)));
}
BUILT_IN_COMMAND_IDS is the readonly list of every valid built-in id. PublicToolbarItemId is the matching type.

Tiny skeleton

The whole picture in one file:
import { SuperDoc } from 'superdoc';
import { createSuperDocUI } from 'superdoc/ui';
import 'superdoc/style.css';

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
});

const ui = createSuperDocUI({ superdoc });
const scope = ui.createScope();

scope.add(
  ui.commands.bold.observe((state) => {
    document.querySelector('#bold')!.classList.toggle('active', state.active);
  }),
);

document.querySelector('#bold')!.addEventListener('click', () => {
  ui.commands.bold.execute();
});

const teardown = () => {
  ui.destroy();
  superdoc.destroy();
};
window.addEventListener('beforeunload', teardown);

What ships

SurfacePurpose
createSuperDocUI({ superdoc })One controller per editor mount
ui.createScope()Lifecycle bag for subscriptions, registrations, DOM listeners
ui.<domain>.observe(snapshot => ...)Read state. Domains: toolbar, commands.<id>, comments, trackChanges, selection, document
ui.<domain>.<action>(...)Mutate. Examples: ui.comments.resolve(id), ui.trackChanges.accept(id), ui.document.setMode('suggesting')
ui.commands.has(id) / require(id)Validate config-driven ids
BUILT_IN_COMMAND_IDSReadonly list of every built-in command id
ui.destroy()Teardown. Cascades into every live scope.
See the API reference for full signatures.

Common pitfalls

Without ui.destroy(), internal listeners and any live scope keep running after your app unmounts. Hot-reload sessions accumulate dead controllers. Always call it on unload and on every framework-specific destroy hook (onScopeDispose in Vue, onDestroy in Svelte, DestroyRef in Angular).
A composer that reads ui.selection.getSnapshot() at submit time will see null if the user typed in a textarea between opening the composer and pressing Send. Capture the selection at composer-open with ui.selection.capture() and pass the snapshot into ui.comments.createFromCapture(capture, { text }).
Pass modules: { comments: false } to new SuperDoc(...) to disable the built-in comment bubble. Same shape for tracked changes. Document-level features (DOCX import/export, comments round-trip) keep working.