# 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](/editor/built-in-ui/comments).

Complete [Custom UI controller setup](/editor/custom-ui/controller-setup) first. This page uses the same ready-Editor and cleanup lifecycle.

## Add the comments surface [#add-the-comments-surface]

Create a composer, status message, thread list, and Editor container:

```html
<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](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX.

## Bind state and actions [#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.

```ts
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: 'alex@example.com',
  },
  // 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()` and `reopen()` change the thread lifecycle and return receipts.
* `setActive()` and `scrollTo()` 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](/editor/custom-ui/controller-setup) covers the rest for an application that owns more of the interface.

## Choose a live or captured selection [#choose-a-live-or-captured-selection]

The example captures the document selection as soon as it becomes available. This matters because focusing the textarea can clear the live Editor selection.

Use `createFromSelection()` only when the action runs while the Editor selection is still live:

```ts
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](/editor/custom-ui/selection-and-viewport) covers the complete focus and geometry lifecycle.

## Handle failures in the interface [#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](/document-api/comments).
