Build a custom comments UI
Render comment threads from reactive Editor state and run comment actions through the public UI controller.
Use ui.comments when your application owns the comments panel. The handle provides reactive thread state, selection-aware creation, focus and navigation, and mutation receipts.
If SuperDoc should render the panel instead, see built-in comments.
Complete Custom UI controller setup first. This page uses the same ready-Editor and cleanup lifecycle.
Add the comments surface
Create a composer, status message, thread list, and Editor container:
<aside aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments</h2>
<textarea id="comment-text" placeholder="Add a comment to the selected text"></textarea>
<button id="add-comment" type="button" disabled>Add comment</button>
<p id="comments-status" role="status">Select text to add a comment.</p>
<ul id="comment-list"></ul>
</aside>
<div id="editor" style="height: 70vh"></div>
<script type="module" src="/src/main.ts"></script>
Copy the tracked-changes fixture to your app's public directory as contract.docx, or use another DOCX.
Bind state and actions
Read the Editor's controller, superdoc.ui, after onReady. Observe selection and comments separately so each part of the interface updates from its canonical state.
import { SuperDoc } from 'superdoc';
import type { CommentsSlice, SelectionCapture, SelectionSlice } from 'superdoc/ui';
import 'superdoc/style.css';
const commentText = document.querySelector<HTMLTextAreaElement>('#comment-text');
const addComment = document.querySelector<HTMLButtonElement>('#add-comment');
const commentList = document.querySelector<HTMLUListElement>('#comment-list');
const commentsStatus = document.querySelector<HTMLParagraphElement>('#comments-status');
if (!commentText || !addComment || !commentList || !commentsStatus) {
throw new Error('The comments UI is incomplete.');
}
let capturedSelection: SelectionCapture | null = null;
let stopSelection: (() => void) | null = null;
let stopComments: (() => void) | null = null;
let removeHandlers: (() => void) | null = null;
const updateComposer = () => {
addComment.disabled = !capturedSelection || commentText.value.trim().length === 0;
};
const superdoc = new SuperDoc({
selector: '#editor',
document: '/contract.docx',
user: {
name: 'Alex Rivera',
email: '[email protected]',
},
// This application owns the comments presentation, so turn SuperDoc's own
// off. It removes interface only: threads in the DOCX are still parsed, and
// the composer below still creates, resolves, and reopens them through the
// controller. The rest of SuperDoc's built-in surfaces stay, because this
// example replaces the comments panel and nothing else.
ui: {
comments: false,
},
onReady: ({ superdoc: readySuperDoc }) => {
const ui = readySuperDoc.ui;
const renderSelection = (selection: SelectionSlice) => {
if (!selection.empty) capturedSelection = ui.selection.capture();
updateComposer();
};
const renderComments = (comments: CommentsSlice) => {
commentsStatus.textContent = comments.status === 'pending' ? 'Loading comments…' : `${comments.total} comments`;
commentList.replaceChildren();
for (const comment of comments.items) {
const row = document.createElement('li');
const body = document.createElement('span');
const show = document.createElement('button');
const resolve = document.createElement('button');
body.textContent = comment.text || 'Comment without text';
show.type = 'button';
show.textContent = 'Show';
show.addEventListener('click', async () => {
ui.comments.setActive(comment.id);
const result = await ui.comments.scrollTo(comment.id);
if (!result.success) commentsStatus.textContent = result.reason ?? 'The comment could not be shown.';
});
resolve.type = 'button';
resolve.textContent = comment.status === 'resolved' ? 'Reopen' : 'Resolve';
resolve.addEventListener('click', async () => {
const receipt =
comment.status === 'resolved'
? await ui.comments.reopen(comment.id)
: await ui.comments.resolve(comment.id);
if (!receipt.success) commentsStatus.textContent = receipt.failure.message;
});
row.append(body, show, resolve);
commentList.append(row);
}
};
const createComment = async () => {
if (!capturedSelection) return;
const receipt = await ui.comments.createFromCapture(capturedSelection, { text: commentText.value.trim() });
if (!receipt.success) {
commentsStatus.textContent = receipt.failure.message;
return;
}
commentText.value = '';
capturedSelection = null;
updateComposer();
};
renderSelection(ui.selection.getSnapshot());
renderComments(ui.comments.getSnapshot());
stopSelection = ui.selection.observe(renderSelection);
stopComments = ui.comments.observe(renderComments);
commentText.addEventListener('input', updateComposer);
addComment.addEventListener('click', createComment);
removeHandlers = () => {
commentText.removeEventListener('input', updateComposer);
addComment.removeEventListener('click', createComment);
};
},
});
window.addEventListener('beforeunload', () => {
stopSelection?.();
stopComments?.();
removeHandlers?.();
superdoc.destroy();
});
The example uses four parts of the public comments handle:
observe()updates the list when comments or their status change.createFromCapture()anchors a new thread after the composer takes focus.resolve()andreopen()change the thread lifecycle and return receipts.setActive()andscrollTo()coordinate the custom list with the document canvas.
It also mounts with ui: { comments: false }. Without it, SuperDoc renders its own comments sidebar beside yours and the reader gets two comment interfaces on one document. Turning it off removes the built-in presentation only: threads in the DOCX are still parsed, and every action above still works through the controller.
That is the only surface this example switches off, because the comments panel is the only one it replaces. Custom UI controller setup covers the rest for an application that owns more of the interface.
Choose a live or captured selection
The example captures the document selection as soon as it becomes available, so the comment is anchored to the text the reader had selected when they started writing rather than to whatever is selected when they submit.
This Editor keeps its selection when a control elsewhere on the page takes focus, so capture is not a workaround for losing it. Capture is how you freeze the intended target: once a draft is open, changing the selection in the document does not move the pending comment.
Use createFromSelection() only when the action runs while the Editor selection is still live:
const receipt = await ui.comments.createFromSelection({
text: 'Please verify this clause.',
});
if (!receipt.success) console.error(receipt.failure.message);For a modal, textarea, or detached composer, use ui.selection.capture() followed by createFromCapture(), as the complete example does. Do not reconstruct a comment target from DOM ranges. The capture carries a document address that remains meaningful when layout changes. Preserve selections and position UI covers the complete focus and geometry lifecycle.
A capture stays usable for as long as its target still resolves. capturedAt records when the selection was frozen so your interface can show it; it is not an expiry, and elapsed time alone never invalidates a capture. Submitting with nothing captured fails the same way as submitting with an empty selection: both return a NO_SELECTION receipt before any document mutation runs.
Let an author correct a comment
edit() replaces a comment's body text:
const receipt = await ui.comments.edit(commentId, {
text: 'Please verify this clause against schedule B.',
});
if (!receipt.success) console.error(receipt.failure.message);Editing is a separate permission from resolving. A document configured with allowResolve: false still permits body edits, because that setting governs the resolve and reopen transition rather than authorship. A read-only comments configuration refuses both.
Build the panel in React
The example above is deliberately small. A real panel has threads, replies, an edit affordance, a delete confirmation your product owns, and somewhere sensible for focus to land after each of those.
This React version implements the complete lifecycle against the same controller:
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import {
SuperDocUIProvider,
useSetSuperDoc,
useSuperDocComments,
useSuperDocDocument,
useSuperDocSelection,
useSuperDocUI,
} from 'superdoc/ui/react';
import type { CommentInfo, SelectionCapture, WorkflowReceipt } from 'superdoc/ui';
import 'superdoc/style.css';
// The one surface this application replaces. Everything else — toolbar, context
// menu, search, the document canvas — stays built in. Declared at module scope
// because a new object on every render would rebuild the Editor.
const EDITOR_UI = {
comments: false,
} satisfies UIConfig;
const CURRENT_USER = { name: 'Alex Rivera', email: '[email protected]' };
export default function App() {
return (
<SuperDocUIProvider>
<main className='layout'>
<Editor />
<CommentsPanel />
</main>
</SuperDocUIProvider>
);
}
function Editor() {
const mountRef = useRef<HTMLDivElement>(null);
const setSuperDoc = useSetSuperDoc();
useEffect(() => {
if (!mountRef.current) return;
// `onReady` is asynchronous, so a mount that is torn down before the
// document finishes loading can still fire it. Under StrictMode, React
// does exactly that on every dev mount. Binding then would publish a
// destroyed instance and leave the panel reading dead state.
let destroyed = false;
const superdoc = new SuperDoc({
selector: mountRef.current,
document: '/contract.docx',
user: CURRENT_USER,
ui: EDITOR_UI,
onReady: ({ superdoc: ready }) => {
if (!destroyed) setSuperDoc(ready);
},
onException: ({ error }) => console.error('SuperDoc could not open the document.', error),
});
// The component owns the Editor, so the component destroys it. That also
// disposes the controller the provider is publishing.
return () => {
destroyed = true;
superdoc.destroy();
};
}, [setSuperDoc]);
return <div className='canvas' ref={mountRef} />;
}
function CommentsPanel() {
const ui = useSuperDocUI();
const comments = useSuperDocComments();
const selection = useSuperDocSelection();
const { mode } = useSuperDocDocument();
const [status, setStatus] = useState('');
const composerRef = useRef<HTMLTextAreaElement>(null);
// Viewing mode is the one refusal this panel can anticipate: it is on the
// public document slice. The comments `readOnly` and `allowResolve`
// interaction policies are NOT publicly observable, so controls they forbid
// stay visible and surface their refusal through the receipt instead. That is
// a presentation limit, not a safety one — the controller refuses those
// mutations either way.
const readOnly = mode === 'viewing';
// Announce through a live region rather than an alert, so a failure reaches a
// screen reader without stealing focus from the composer the user is in.
const announce = useCallback((message: string) => setStatus(message), []);
const report = useCallback(
(receipt: Awaited<WorkflowReceipt>, success: string) => {
if (!receipt.success) {
announce(receipt.failure.message);
return false;
}
announce(success);
return true;
},
[announce],
);
const threads = useMemo(() => toThreads(comments.items), [comments.items]);
if (!ui) {
return (
<aside aria-label='Comments' className='panel'>
<p>Loading the document…</p>
</aside>
);
}
return (
<aside aria-label='Comments' className='panel'>
<header>
<h2>Comments</h2>
<p>{comments.listStatus === 'pending' ? 'Loading…' : `${threads.length} threads`}</p>
</header>
{!readOnly && (
<NewCommentComposer
announce={announce}
composerRef={composerRef}
report={report}
selectionIsEmpty={selection.empty}
ui={ui}
/>
)}
{comments.listStatus !== 'pending' && threads.length === 0 && (
<p className='empty'>No comments yet. Select text in the document to start a thread.</p>
)}
<ul className='threads'>
{threads.map((thread) => (
<Thread
active={thread.root.id === comments.activeId}
announce={announce}
key={thread.root.id}
readOnly={readOnly}
report={report}
returnFocusTo={composerRef}
thread={thread}
ui={ui}
/>
))}
</ul>
<p aria-live='polite' className='status' role='status'>
{status}
</p>
</aside>
);
}
type UIHandle = NonNullable<ReturnType<typeof useSuperDocUI>>;
type Report = (receipt: Awaited<WorkflowReceipt>, success: string) => boolean;
type Announce = (message: string) => void;
function NewCommentComposer({
announce,
composerRef,
report,
selectionIsEmpty,
ui,
}: {
announce: Announce;
composerRef: React.RefObject<HTMLTextAreaElement | null>;
report: Report;
selectionIsEmpty: boolean;
ui: UIHandle;
}) {
const [text, setText] = useState('');
const [capture, setCapture] = useState<SelectionCapture | null>(null);
const [pending, setPending] = useState(false);
const fieldId = useId();
// Capture on the press, before focus moves anywhere. `mousedown` fires
// before the browser moves focus to the button; `click` fires after. Reading
// the selection at submit time instead would tie the comment to whatever is
// selected then, which is not what the user was looking at when they started
// writing.
const captureNow = useCallback(() => {
const frozen = ui.selection.capture();
if (frozen) setCapture(frozen);
}, [ui]);
const startComment = useCallback(() => {
// Keyboard activation never fires mousedown, so the click handler is the
// only hook the keyboard path has. That works because this controller keeps
// its selection when a control takes focus — it is NOT the same guarantee
// as capturing on the press. A control that cleared the selection on focus
// would need a keydown handler instead.
captureNow();
composerRef.current?.focus();
}, [captureNow, composerRef]);
const submit = useCallback(async () => {
if (!capture || pending) return;
setPending(true);
try {
// The capture is the target, not the live selection. A selection change
// made while this composer was open does not retarget the draft.
const created = report(await ui.comments.createFromCapture(capture, { text }), 'Comment added.');
if (!created) return;
setText('');
setCapture(null);
} finally {
setPending(false);
}
}, [capture, pending, report, text, ui]);
const cancel = useCallback(() => {
setText('');
setCapture(null);
announce('Draft discarded.');
}, [announce]);
return (
<form
className='composer'
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<button disabled={selectionIsEmpty && !capture} onMouseDown={captureNow} onClick={startComment} type='button'>
Add comment
</button>
<label htmlFor={fieldId}>New comment</label>
<textarea
id={fieldId}
onChange={(event) => setText(event.target.value)}
placeholder='Select text in the document, then write your comment.'
ref={composerRef}
rows={3}
value={text}
/>
<div className='actions'>
<button disabled={!capture || pending || text.trim().length === 0} type='submit'>
{pending ? 'Adding…' : 'Comment'}
</button>
<button disabled={pending} onClick={cancel} type='button'>
Cancel
</button>
</div>
</form>
);
}
function Thread({
active,
announce,
readOnly,
report,
returnFocusTo,
thread,
ui,
}: {
active: boolean;
announce: Announce;
readOnly: boolean;
report: Report;
returnFocusTo: React.RefObject<HTMLTextAreaElement | null>;
thread: CommentThread;
ui: UIHandle;
}) {
const { root, replies } = thread;
const resolved = root.status === 'resolved';
const reveal = useCallback(async () => {
ui.comments.setActive(root.id);
const shown = await ui.comments.scrollTo(root.id);
// Activation and navigation are separate outcomes. Reporting success when
// the anchor could not be reached would hide a real failure.
if (!shown.success) announce(shown.reason ?? 'The comment could not be shown.');
}, [announce, root.id, ui]);
return (
<li className={active ? 'thread active' : 'thread'}>
<article aria-current={active ? 'true' : undefined}>
<CommentBody comment={root} readOnly={readOnly} report={report} ui={ui} />
{replies.map((reply) => (
<CommentBody comment={reply} key={reply.id} readOnly={readOnly} report={report} reply ui={ui} />
))}
<div className='actions'>
<button onClick={() => void reveal()} type='button'>
Show in document
</button>
{!readOnly && (
<>
<button
onClick={async () => {
const receipt = resolved ? await ui.comments.reopen(root.id) : await ui.comments.resolve(root.id);
report(receipt, resolved ? 'Thread reopened.' : 'Thread resolved.');
}}
type='button'
>
{resolved ? 'Reopen' : 'Resolve'}
</button>
<DeleteThread commentId={root.id} report={report} returnFocusTo={returnFocusTo} ui={ui} />
</>
)}
</div>
{!readOnly && !resolved && <ReplyComposer commentId={root.id} report={report} ui={ui} />}
</article>
</li>
);
}
function CommentBody({
comment,
readOnly,
reply = false,
report,
ui,
}: {
comment: CommentInfo;
readOnly: boolean;
reply?: boolean;
report: Report;
ui: UIHandle;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(comment.text ?? '');
const [pending, setPending] = useState(false);
const fieldId = useId();
const editButtonRef = useRef<HTMLButtonElement>(null);
// The Edit button is unmounted while the editor is open, so its ref is null
// for as long as `editing` is true. Focusing it in the same handler that
// clears `editing` therefore does nothing: React has not re-rendered yet.
// Restore focus in an effect, after the button is back in the tree.
const restoreFocus = useRef(false);
useEffect(() => {
if (editing || !restoreFocus.current) return;
restoreFocus.current = false;
editButtonRef.current?.focus();
}, [editing]);
const stopEditing = useCallback(() => {
// Cancel and success both return focus to the control that opened the
// editor, so keyboard users are not dropped at the top of the panel.
restoreFocus.current = true;
setEditing(false);
}, []);
// A mode switch can land while an editor is open. Clearing the state here
// rather than only hiding the form matters twice: the draft does not reappear
// if the document returns to editing, and the guard on the early return below
// cannot be defeated by state that outlived the mode it belonged to.
useEffect(() => {
if (!readOnly || !editing) return;
setEditing(false);
setDraft(comment.text ?? '');
}, [comment.text, editing, readOnly]);
// Rendering the editor is gated on `readOnly` because this early return
// happens before the write controls below reach their own guard.
if (editing && !readOnly) {
return (
<form
className='edit'
onSubmit={async (event) => {
event.preventDefault();
if (pending) return;
setPending(true);
try {
if (report(await ui.comments.edit(comment.id, { text: draft }), 'Comment updated.')) stopEditing();
} finally {
setPending(false);
}
}}
>
<label htmlFor={fieldId}>Edit comment</label>
<textarea autoFocus id={fieldId} onChange={(event) => setDraft(event.target.value)} rows={2} value={draft} />
<button disabled={pending || draft.trim().length === 0} type='submit'>
{pending ? 'Saving…' : 'Save'}
</button>
<button
disabled={pending}
onClick={() => {
setDraft(comment.text ?? '');
stopEditing();
}}
type='button'
>
Cancel
</button>
</form>
);
}
return (
<div className={reply ? 'comment reply' : 'comment'}>
<p className='author'>{comment.creatorName ?? 'Unknown author'}</p>
<p className='body'>{comment.text || 'Comment without text'}</p>
{!readOnly && (
<button
onClick={() => {
setDraft(comment.text ?? '');
setEditing(true);
}}
ref={editButtonRef}
type='button'
>
Edit
</button>
)}
</div>
);
}
function ReplyComposer({ commentId, report, ui }: { commentId: string; report: Report; ui: UIHandle }) {
const [text, setText] = useState('');
const [pending, setPending] = useState(false);
const fieldId = useId();
return (
<form
className='reply-composer'
onSubmit={async (event) => {
event.preventDefault();
if (pending) return;
setPending(true);
try {
if (report(await ui.comments.reply(commentId, { text }), 'Reply added.')) setText('');
} finally {
setPending(false);
}
}}
>
<label htmlFor={fieldId}>Reply</label>
<input id={fieldId} onChange={(event) => setText(event.target.value)} type='text' value={text} />
<button disabled={pending || text.trim().length === 0} type='submit'>
{pending ? 'Replying…' : 'Reply'}
</button>
</form>
);
}
function DeleteThread({
commentId,
report,
returnFocusTo,
ui,
}: {
commentId: string;
report: Report;
returnFocusTo: React.RefObject<HTMLTextAreaElement | null>;
ui: UIHandle;
}) {
const [confirming, setConfirming] = useState(false);
const [pending, setPending] = useState(false);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
// Same unmount problem as the edit affordance: the Delete button is not in
// the tree while the confirmation is showing, so focusing its ref in the same
// handler that hides the confirmation is a no-op.
const restoreFocus = useRef(false);
useEffect(() => {
if (confirming || !restoreFocus.current) return;
restoreFocus.current = false;
deleteButtonRef.current?.focus();
}, [confirming]);
const closeConfirmation = useCallback(() => {
restoreFocus.current = true;
setConfirming(false);
}, []);
// Confirmation belongs to the application. The controller deletes what it is
// told to delete and does not prompt.
if (!confirming) {
return (
<button onClick={() => setConfirming(true)} ref={deleteButtonRef} type='button'>
Delete
</button>
);
}
return (
<span className='confirm' role='group' aria-label='Confirm deletion'>
<span>Delete this thread?</span>
<button
autoFocus
disabled={pending}
onClick={async () => {
setPending(true);
try {
// On success the whole thread leaves the list, so focus has to go
// somewhere deliberate rather than to a removed node's ancestor.
// On failure the thread is still here, so returning to the Delete
// button leaves the user where they can retry or move on.
if (report(await ui.comments.delete(commentId), 'Thread deleted.')) returnFocusTo.current?.focus();
else closeConfirmation();
} finally {
setPending(false);
}
}}
type='button'
>
{pending ? 'Deleting…' : 'Delete'}
</button>
<button disabled={pending} onClick={closeConfirmation} type='button'>
Keep
</button>
</span>
);
}
interface CommentThread {
root: CommentInfo;
replies: CommentInfo[];
}
/**
* Group the flat comment feed into threads.
*
* This derives from `comments.items` on every render rather than being copied
* into state, so collaboration updates, undo, and redo stay authoritative. A
* cached thread list would drift from the document.
*/
function toThreads(items: readonly CommentInfo[]): CommentThread[] {
const threads = new Map<string, CommentThread>();
for (const item of items) {
if (!item.parentCommentId) threads.set(item.id, { root: item, replies: [] });
}
for (const item of items) {
const parentId = item.rootCommentId ?? item.parentCommentId;
if (parentId) threads.get(parentId)?.replies.push(item);
}
return [...threads.values()];
}
Three parts of it are worth reading closely.
The pointer path captures on mousedown, before focus moves. mousedown fires while the document still owns the selection; click fires after focus has reached the button. Capturing on the press is what keeps the pointer path correct regardless of what focus does next.
The keyboard path captures in the click handler, and that is not the same guarantee. Keyboard activation never fires mousedown, so the click handler is the only hook available. It works here because this controller keeps its selection when a control takes focus. A control that clears the selection on focus would already be too late by then, and would need a keydown handler instead. Do not read the two paths as sharing one timing guarantee.
The capture is the target, not the live selection. Once the composer is open, changing the selection in the document does not move the pending comment. Submitting sends the frozen capture, so the comment lands on the text the user had selected when they started writing.
The thread list derives from comments.items on every render. It is never copied into React state. Collaboration updates, undo, and redo all change the controller's snapshot, and a cached list would quietly drift from the document.
Creation success and navigation success are reported separately. scrollTo() can fail after a comment was created perfectly well, and telling the user the comment did not save would be wrong.
Focus is restored after a render, not in the handler that closes an affordance. The Edit button is unmounted while its editor is open, so its ref is null until React re-renders. Calling focus() in the same handler that clears the editing state silently does nothing; the example restores focus from an effect instead.
What the panel can and cannot anticipate
The panel disables its write controls in viewing mode, which it reads from the public document slice.
It does not pre-emptively hide controls for the readOnly and allowResolve comment interaction policies, because those are not publicly observable from the controller. Actions they forbid stay visible and surface their refusal through the receipt. That is a presentation limit rather than a safety one: the controller refuses those mutations either way, and client-side controls were never the authorization boundary.
React custom UI setup covers the provider and binding lifecycle this example assumes.
Handle failures in the interface
Comment actions can fail because the Editor is not ready, the selection is empty, the target is stale, or the operation is unavailable. Keep the composer open and show the receipt message when an action fails.
Client-side controls are not an authorization boundary. Enforce document access and trusted identity outside the Editor.
For direct document operations without a custom panel, continue with Document API comments.