Choose your editor interface
Decide how much of the editor interface SuperDoc renders and how much your application owns.
Every Editor integration uses the same DOCX engine, document lifecycle, and public Document API. The interface decision determines who renders the controls around the document.
Three ownership modes
Config.ui is the single dial. Which mode you are in is a consequence of what you pass it, not a separate architecture to commit to.
| Approach | Configuration | Who renders the chrome |
|---|---|---|
| Built-in | Name a toolbar mount, omit the rest | SuperDoc renders its default surfaces |
| Hybrid | Configure selected surfaces | SuperDoc and your application split it |
| Fully custom | ui: false | Your application renders all of it via superdoc.ui |
Start with the built-in UI unless a product requirement clearly needs custom controls. It is the shortest path to a complete, accessible editing workflow, and it shows the team which interactions actually need customizing before anything is rebuilt.
Moving between modes is a configuration change. Nothing about how the document is opened, represented, or saved changes with it.
All three examples below load /contract.docx. Copy the tracked-changes fixture to your app's public directory under that name, or update the document URL.
Built-in
Surfaces you say nothing about keep their historical defaults. The toolbar, comments, context menu, link popover, content controls, and the loading overlay render; search and the ruler are opt-in, so add ui: { search: true } or ui: { ruler: true } when you want them.
The built-in toolbar keeps the Search button visible, but disables it until you opt in.
The one thing this mode does need is a mount target for the toolbar. SuperDoc cannot guess where in your layout that belongs, so a toolbar with nowhere to go does not render.
<div id="toolbar"></div>
<div id="editor" style="height: 70vh"></div>
<script type="module" src="/src/main.ts"></script>
import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';
// Built-in: SuperDoc renders the chrome.
//
// Surfaces you say nothing about keep their historical defaults. The toolbar is
// the one that needs a mount target, because SuperDoc cannot guess where in
// your layout it belongs.
const superdoc = new SuperDoc({
selector: '#editor',
document: '/contract.docx',
ui: {
toolbar: { container: '#toolbar' },
// The default toolbar renders a Search button regardless, and it opens
// the shared find/replace surface. Leaving `search` off gives a control
// that is visible, enabled, and does nothing when clicked.
search: true,
},
});
window.addEventListener('beforeunload', () => superdoc.destroy());
Choose this for document editors, review screens, and internal workflows where the standard experience already fits. You still control document mode, the current user, integrations, theme, file lifecycle, and export.
Hybrid
Most production integrations land here. Replace the one surface your product needs to own and keep the rest.
<div id="toolbar"></div>
<aside aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments</h2>
<div id="comments-panel"></div>
<label for="comment-text">New comment</label>
<textarea id="comment-text" rows="3"></textarea>
<button id="add-comment" type="button" disabled>Add comment</button>
</aside>
<div id="editor" style="height: 70vh"></div>
<script type="module" src="/src/main.ts"></script>
import { SuperDoc } from 'superdoc';
import type { BorrowedSuperDocUI, CommentsSlice, SelectionCapture, SelectionSlice } from 'superdoc/ui';
import 'superdoc/style.css';
const panelElement = document.querySelector<HTMLElement>('#comments-panel');
const composerText = document.querySelector<HTMLTextAreaElement>('#comment-text');
const composerSubmit = document.querySelector<HTMLButtonElement>('#add-comment');
if (!panelElement || !composerText || !composerSubmit) {
throw new Error('The comments panel is missing.');
}
const panel = panelElement;
const commentText = composerText;
const addComment = composerSubmit;
// The capture taken while text was selected. Creating a comment needs a
// document address rather than a live DOM range, so it is taken when the
// selection exists and used later when the composer is submitted.
let capturedSelection: SelectionCapture | null = null;
/**
* The application's own comments surface, in place of the built-in one.
*
* Clicking a row focuses that comment and scrolls the document to it, which
* is the behavior the built-in panel provided before `ui.comments: false`
* turned it off.
*
* The observer fires on every change, including the one a click here causes,
* so the rows are rebuilt underneath the button the user just pressed. Each
* row carries its comment id and focus is restored afterwards; without that,
* keyboard and screen-reader users are returned to the top of the document
* after every activation.
*/
function renderCommentPanel(ui: BorrowedSuperDocUI, slice: CommentsSlice) {
const focusedId =
document.activeElement instanceof HTMLElement && panel.contains(document.activeElement)
? document.activeElement.dataset.commentId
: undefined;
panel.replaceChildren(
...slice.items.map((comment) => {
const row = document.createElement('button');
row.type = 'button';
row.dataset.commentId = comment.id;
// A comment can carry no text. Falling back to an empty string would
// leave a focusable control that assistive technology cannot name.
row.textContent = comment.text || 'Comment without text';
row.addEventListener('click', () => {
ui.comments.setActive(comment.id);
void ui.comments.scrollTo(comment.id);
});
return row;
}),
);
if (focusedId) {
panel.querySelector<HTMLButtonElement>(`[data-comment-id="${CSS.escape(focusedId)}"]`)?.focus();
}
}
// Hybrid: SuperDoc and the application split the chrome.
//
// Three keys are named here and every other surface keeps its default. The
// application renders its own comments panel, so the built-in one is turned
// off; the toolbar is given a mount target; and search is opted into, because
// it is off by default and the toolbar's Search button needs it. `ui` keys are
// independent, so naming these three says nothing about the rest.
const superdoc = new SuperDoc({
selector: '#editor',
document: '/contract.docx',
ui: {
toolbar: { container: '#toolbar' },
comments: false,
// The default toolbar's Search button opens this surface. Without it the
// control still renders and clicking it does nothing.
search: true,
},
// Rendering and permission are separate decisions. Turning off the built-in
// comments interface does not stop this panel from writing, so the policy is
// stated rather than inferred from `ui`. `readOnly: false` is the default;
// it is written out because the point of the pair is that `ui` never
// decides it. `allowResolve: false` permits replies while forbidding resolve.
//
// `readOnly: true` is deliberately not the example here: it refuses tracked
// change accept and reject as well as comment writes, so it takes the review
// workflow with it. Reach for it when the whole surface should not mutate
// the document, not to make one panel non-writing.
interaction: { comments: { readOnly: false, allowResolve: true } },
onReady: ({ superdoc: readySuperDoc }) => {
// The controller stays available for the surfaces the application owns.
const ui = readySuperDoc.ui;
// Capture while the selection exists; the composer is submitted later,
// after focus has moved to the textarea and the selection is gone.
//
// Submit needs both halves: something to attach the comment to, and
// something to say. Gating on the capture alone leaves an enabled button
// whose click returns silently, which reads as a broken control.
const syncComposer = () => {
addComment.disabled = !capturedSelection || commentText.value.trim().length === 0;
};
const trackSelection = (selection: SelectionSlice) => {
if (!selection.empty) capturedSelection = ui.selection.capture();
syncComposer();
};
commentText.addEventListener('input', syncComposer);
addComment.addEventListener('click', async () => {
if (!capturedSelection || !commentText.value.trim()) return;
const receipt = await ui.comments.createFromCapture(capturedSelection, { text: commentText.value.trim() });
if (!receipt.success) return;
commentText.value = '';
capturedSelection = null;
syncComposer();
});
trackSelection(ui.selection.getSnapshot());
ui.selection.observe(trackSelection);
renderCommentPanel(ui, ui.comments.getSnapshot());
ui.comments.observe((slice) => renderCommentPanel(ui, slice));
},
});
window.addEventListener('beforeunload', () => superdoc.destroy());
The panel starts empty against the fixture above, which carries no comments. Select text and add one through your own composer, or open a DOCX that already has threads, to see it populate. Build a custom comments UI covers creation, replies, and resolution on the same handle.
Each ui key is independent, so a partial object is additive: naming comments says nothing about the toolbar. Turn off only the surfaces you have actually rebuilt, because a surface you disable without replacing leaves the reader with less than SuperDoc would have given them.
This is also where the difference between rendering and permission matters most. ui decides what SuperDoc draws; interaction decides what a person may do. Your own comments panel is not bound by the built-in one's absence, so state the policy rather than inferring it.
The loading overlay is the one surface where turning it off transfers a responsibility rather than just removing pixels. ui: { loading: false } stops SuperDoc drawing the overlay it shows while a document opens. The overlay also masks the document underneath while it renders, so without it your UI owns that window. Keep your own loading state up until onReady, and around a replacement await superdoc.replaceFile(...) rather than assuming it returns instantly. A replacement reopens the document, so the built-in overlay returns for it unless you have turned it off.
In React, renderLoading and ui.loading are independent. renderLoading is your loading UI and SuperDoc hides it once the instance reports ready; ui.loading controls SuperDoc's own. Pass ui={{ loading: false }} alongside renderLoading so the two do not appear one after the other.
The two comment policies answer different questions. readOnly refuses every write, including create, reply, and delete. allowResolve refuses only the resolve and reopen transition, leaving replies alone. Both default to permissive, so a panel that should not write needs readOnly: true said out loud.
readOnly reaches further than its name suggests: it also refuses tracked-change accept and reject. Their command state reports reason: 'document-readonly' while it is set, and direct ui.trackChanges.accept() or reject() calls return false. Reviewers keep reading and commenting rules but lose the ability to decide changes, so reserve it for surfaces that should not mutate the document at all rather than using it to make one comment panel non-writing.
Fully custom
ui: false turns off every built-in surface at once.
<style>
/* The application owns every control here, so it owns their states too. */
#bold[aria-pressed='true'] {
background: #1a1a1a;
color: #fff;
}
#bold:disabled {
opacity: 0.5;
}
</style>
<div role="toolbar" aria-label="Formatting">
<!-- Disabled until `onReady` attaches the handler and the first command
state arrives. A DOCX can take a moment to load, or fail to, and an
enabled button in that window swallows clicks silently. -->
<button id="bold" type="button" aria-pressed="false" disabled>Bold</button>
</div>
<div id="editor" style="height: 70vh"></div>
<script type="module" src="/src/main.ts"></script>
import { SuperDoc } from 'superdoc';
import type { CommandState } from 'superdoc/ui';
import 'superdoc/style.css';
const boldButton = document.querySelector<HTMLButtonElement>('#bold')!;
// Fully custom: the application renders every control.
//
// `ui: false` turns off all built-in chrome at once. It removes presentation
// only: editing, the Document API, `interaction`, `surfaces`, and
// `superdoc.ui` all keep working, which is what makes this viable without
// giving up the editor underneath.
const superdoc = new SuperDoc({
selector: '#editor',
document: '/contract.docx',
ui: false,
// Still enforced with no built-in UI to enforce it in. Policy is not a
// property of the chrome that happens to render it.
interaction: { comments: { readOnly: true } },
onReady: ({ superdoc: readySuperDoc }) => {
const bold = readySuperDoc.ui.commands.get('bold');
const render = (state: CommandState) => {
boldButton.disabled = !state.enabled;
// A toggle's pressed state has to reach assistive technology, not only
// a class name. `aria-pressed` announces it and drives the styling in
// the markup beside this file, so there is one source of truth.
boldButton.setAttribute('aria-pressed', String(state.active));
};
render(bold.getState());
bold.observe(render);
boldButton.addEventListener('click', () => bold.execute());
},
});
window.addEventListener('beforeunload', () => superdoc.destroy());
It removes presentation and nothing else. Editing still works, the Document API still works, interaction and surfaces still apply, and superdoc.ui still reports state and runs commands. That invariant is what makes a fully custom interface possible without giving up the editor underneath it.
Choose this when the surrounding experience is part of the product's differentiation: a focused contract approval screen, a form-like document workflow, or controls that must match an established design system.
superdoc/ui exposes a framework-neutral controller and superdoc/ui/react adds React providers and hooks over it. Both operate against the same active Editor and Document API.
Understand the custom UI model.
Keep the lifecycle the same
Whichever mode you choose:
- Create a
SuperDoceditor with a DOCX and a visible container. - Wait for
onReadybefore binding document-dependent controls. - Read state and run commands through public surfaces.
- Inspect receipts for programmatic changes.
- Export the DOCX and call
editor.destroy()when finished. That tears downeditor.uiwith it, so destroy a controller yourself only if you built it withcreateSuperDocUI().
Configure the Editor explains how ui, interaction, and surfaces divide the configuration between them.