Custom UI

Build a custom comments panel

Render comment threads, create one from selected text, and connect each row to its document anchor.

Keep the built-in comments UI when its layout and actions fit your product. Build a custom panel when your application needs to own the markup or workflow.

This guide covers one complete custom workflow: list root threads, add a comment to selected text, navigate to its anchor, and resolve or reopen it. Use Document API comments for direct operations and the complete comment lifecycle.

Try the custom workflow

The example replaces only the comments panel. SuperDoc still renders its toolbar and a three-page DOCX.

  1. Choose Show in document on each existing thread. The document moves between the first and final pages.
  2. On the middle page, select approval criteria, then choose Comment on selection and add a comment.
  3. Resolve or reopen a thread.

The panel is application markup driven by ui.comments. The document, selection, comment anchors, and mutations still belong to SuperDoc.

Build the same panel

Start from Build your first custom control. This example keeps /sample.docx, the built-in toolbar, and every other built-in surface so comments remain the only new concern. If you already built a custom toolbar, keep it and apply the same comments changes.

Vanilla needs toolbar, Editor, and panel mounts:

<div id="toolbar"></div>

<main class="comments-layout">
  <div id="editor" style="height: 70vh"></div>

  <aside aria-labelledby="comments-heading">
    <h2 id="comments-heading">Comments</h2>
    <p id="comment-count">Opening document...</p>

    <button id="start-comment" type="button" disabled>Comment on selection</button>
    <form id="comment-composer" hidden>
      <label for="comment-text">New comment</label>
      <textarea id="comment-text" rows="3"></textarea>
      <button id="add-comment" type="submit" disabled>Add comment</button>
      <button id="cancel-comment" type="button">Cancel</button>
    </form>

    <p id="comments-status" role="status">Select text to start a comment.</p>
    <ul id="comment-list"></ul>
  </aside>
</main>

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

Replace the setup guide's Editor code:

src/main.ts
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { CommentsSlice, SelectionCapture, SelectionSlice, WorkflowReceipt } from 'superdoc/ui';
import 'superdoc/style.css';

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing comments element: ${selector}`);
  return element;
}

const startComment = getElement<HTMLButtonElement>('#start-comment');
const toolbar = getElement<HTMLDivElement>('#toolbar');
const composer = getElement<HTMLFormElement>('#comment-composer');
const commentText = getElement<HTMLTextAreaElement>('#comment-text');
const addComment = getElement<HTMLButtonElement>('#add-comment');
const cancelComment = getElement<HTMLButtonElement>('#cancel-comment');
const commentCount = getElement<HTMLParagraphElement>('#comment-count');
const commentsStatus = getElement<HTMLParagraphElement>('#comments-status');
const commentList = getElement<HTMLUListElement>('#comment-list');

const editorUi = {
  comments: false,
  toolbar: { container: toolbar, responsiveTo: 'container' },
} satisfies UIConfig;
let capture: SelectionCapture | null = null;
let stopBindings: (() => void) | null = null;

function report(receipt: Awaited<WorkflowReceipt>, success: string): boolean {
  commentsStatus.textContent = receipt.success ? success : receipt.failure.message;
  return receipt.success;
}

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/sample.docx',
  user: { name: 'Alex Rivera', email: '[email protected]' },
  ui: editorUi,
  onReady: ({ superdoc: readySuperDoc }) => {
    stopBindings?.();
    capture = null;
    composer.hidden = true;
    commentText.value = '';
    addComment.disabled = true;
    const { ui } = readySuperDoc;
    let selectionIsEmpty = true;

    let pendingCapture: SelectionCapture | null = null;

    const updateComposer = () => {
      commentText.disabled = pendingCapture !== null;
      addComment.disabled = pendingCapture !== null || !capture || commentText.value.trim().length === 0;
      cancelComment.disabled = pendingCapture !== null;
    };

    const closeComposer = () => {
      capture = null;
      commentText.value = '';
      composer.hidden = true;
      startComment.disabled = selectionIsEmpty;
      updateComposer();
      startComment.focus();
    };

    const renderSelection = (selection: SelectionSlice) => {
      selectionIsEmpty = selection.empty;
      startComment.disabled = selection.empty || !composer.hidden;
    };

    const renderComments = (commentState: CommentsSlice) => {
      const threads = commentState.items.filter((comment) => !comment.parentCommentId);
      commentCount.textContent =
        commentState.listStatus === 'pending' ? 'Loading comments...' : `${threads.length} threads`;
      commentList.replaceChildren();

      for (const thread of threads) {
        const row = document.createElement('li');
        const body = document.createElement('span');
        const show = document.createElement('button');
        const toggleStatus = document.createElement('button');

        body.textContent = thread.text || 'Comment without text';
        show.type = 'button';
        show.textContent = 'Show in document';
        show.addEventListener('click', async () => {
          if (!ui.comments.setActive(thread.id)) {
            commentsStatus.textContent = 'The comment is no longer available.';
            return;
          }
          const result = await ui.comments.scrollTo(thread.id);
          commentsStatus.textContent = result.success
            ? 'Showing the comment in the document.'
            : (result.reason ?? 'The comment could not be shown.');
        });

        toggleStatus.type = 'button';
        toggleStatus.textContent = thread.status === 'resolved' ? 'Reopen' : 'Resolve';
        toggleStatus.addEventListener('click', async () => {
          const receipt =
            thread.status === 'resolved' ? await ui.comments.reopen(thread.id) : await ui.comments.resolve(thread.id);
          report(receipt, thread.status === 'resolved' ? 'Comment reopened.' : 'Comment resolved.');
        });

        row.append(body, show, toggleStatus);
        commentList.append(row);
      }
    };

    const captureSelection = () => {
      capture = ui.selection.capture();
    };

    const openComposer = (event: MouseEvent) => {
      if (event.detail === 0) capture = ui.selection.capture();
      else capture ??= ui.selection.capture();
      if (!capture) {
        commentsStatus.textContent = 'Select text before starting a comment.';
        return;
      }
      composer.hidden = false;
      startComment.disabled = true;
      commentText.focus();
      updateComposer();
    };

    const createComment = async (event: SubmitEvent) => {
      event.preventDefault();
      if (!capture || pendingCapture) return;
      // Lock the composer until the receipt settles so a repeated submit cannot
      // send the same draft twice, and a draft opened later is not closed by
      // this receipt.
      pendingCapture = capture;
      updateComposer();
      const receipt = await ui.comments.createFromCapture(pendingCapture, { text: commentText.value.trim() });
      const stillCurrent = capture === pendingCapture;
      pendingCapture = null;
      if (report(receipt, 'Comment added.') && stillCurrent) closeComposer();
      else updateComposer();
    };

    renderSelection(ui.selection.getSnapshot());
    renderComments(ui.comments.getSnapshot());
    const stopSelection = ui.selection.observe(renderSelection);
    const stopComments = ui.comments.observe(renderComments);

    startComment.addEventListener('mousedown', captureSelection);
    startComment.addEventListener('click', openComposer);
    commentText.addEventListener('input', updateComposer);
    composer.addEventListener('submit', createComment);
    cancelComment.addEventListener('click', closeComposer);

    stopBindings = () => {
      stopSelection();
      stopComments();
      startComment.removeEventListener('mousedown', captureSelection);
      startComment.removeEventListener('click', openComposer);
      commentText.removeEventListener('input', updateComposer);
      composer.removeEventListener('submit', createComment);
      cancelComment.removeEventListener('click', closeComposer);
    };
  },
  onContentError: ({ error }) => {
    commentsStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
  onException: ({ error }) => {
    commentsStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
});

window.addEventListener('beforeunload', () => {
  stopBindings?.();
  superdoc.destroy();
});

Both versions follow the same loop:

  1. Set ui.comments to false to remove SuperDoc's comments panel without removing comments from the DOCX.
  2. Observe ui.comments and render root threads from its current snapshot.
  3. Capture the document selection before the composer takes focus.
  4. Run createFromCapture(), setActive() with scrollTo(), or resolve() and reopen(). Show the returned result.

Keep the comment target stable

Capture the selection when the composer opens. The pointer path captures on mousedown, before focus moves to the button. Keyboard activation captures in the click handler. Submitting the saved capture keeps the draft anchored to the text that started it, even if the selection changes while someone writes.

Use ui.comments.createFromSelection() only for an immediate action that does not move focus into a composer. Do not reconstruct a comment target from DOM ranges. See Selection and viewport when you also need to position an application-owned popover.

Match the configured actions

With the built-in panel disabled, ui.comments.layout no longer affects your markup. The configured interaction.comments.level still limits controller mutations. Render only the actions your application allows. Handle every receipt because document mode or stale state can still block an action. See the comments configuration reference for the complete contract.

Verify the workflow

Run the project and add a comment to selected text. Confirm that the new row appears, Show in document returns to its anchor, and Resolve changes to Reopen. The built-in toolbar should remain visible, and SuperDoc's comments panel should not appear beside yours.

To accept or reject revisions, build a tracked-change panel. That guide restarts from the setup project with its own document and markup, so it is a separate workflow rather than a panel you add to this one. Otherwise, return to the Custom UI overview and choose another workflow.

On this page