# 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](/editor/built-in-ui/comments) 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](/document-api/comments) for direct operations and the
complete comment lifecycle.

## Try the custom workflow [#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.

> **Live example: replace the comments panel**
>
> SuperDoc renders the toolbar and a three-page DOCX while the application renders the comments panel. Existing threads on the first and final pages make `setActive()` and `scrollTo()` visible; the middle page provides text for `createFromCapture()`. The panel observes `ui.comments` and reports resolve or reopen receipts. Setting `ui.comments` to `false` removes the built-in comments panel without removing comments from the document.


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

## Build the same panel [#build-the-same-panel]

Start from [Build your first custom control](/editor/custom-ui/controller-setup). 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:

```html
<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:

**Vanilla — `src/main.ts`**

```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: 'alex@example.com' },
  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();
});

```

**React — `src/App.tsx`**

```tsx
import { useEffect, useRef, useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { UIConfig } from 'superdoc';
import type { SelectionCapture, WorkflowReceipt } from 'superdoc/ui';
import {
  SuperDocUIProvider,
  useSetSuperDoc,
  useSuperDocComments,
  useSuperDocSelection,
  useSuperDocUI,
} from 'superdoc/ui/react';
import '@superdoc/react/style.css';

const editorUi = { comments: false } satisfies UIConfig;
const currentUser = { name: 'Alex Rivera', email: 'alex@example.com' };

export default function App() {
  return (
    <SuperDocUIProvider>
      <main className='comments-layout'>
        <Editor />
        <CommentsPanel />
      </main>
    </SuperDocUIProvider>
  );
}

function Editor() {
  const setSuperDoc = useSetSuperDoc();

  return (
    <SuperDocEditor
      document='/sample.docx'
      onContentError={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onException={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onReady={({ superdoc }) => setSuperDoc(superdoc)}
      ui={editorUi}
      user={currentUser}
    />
  );
}

function CommentsPanel() {
  const ui = useSuperDocUI();
  const commentState = useSuperDocComments();
  const selection = useSuperDocSelection();
  const startCommentRef = useRef<HTMLButtonElement>(null);
  const pressedCapture = useRef<SelectionCapture | null>(null);
  const restoreFocus = useRef(false);
  const [capture, setCapture] = useState<SelectionCapture | null>(null);
  const [pending, setPending] = useState(false);
  const [text, setText] = useState('');
  const [status, setStatus] = useState('Select text to start a comment.');

  const threads = commentState.items.filter((comment) => !comment.parentCommentId);

  useEffect(() => {
    if (capture || !restoreFocus.current) return;
    restoreFocus.current = false;
    startCommentRef.current?.focus();
  }, [capture]);

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

  function captureSelection() {
    pressedCapture.current = ui?.selection.capture() ?? null;
  }

  function openComposer(event: React.MouseEvent<HTMLButtonElement>) {
    const nextCapture =
      event.detail === 0
        ? (ui?.selection.capture() ?? null)
        : (pressedCapture.current ?? ui?.selection.capture() ?? null);
    pressedCapture.current = null;
    if (!nextCapture) {
      setStatus('Select text before starting a comment.');
      return;
    }
    setCapture(nextCapture);
  }

  function closeComposer() {
    restoreFocus.current = true;
    pressedCapture.current = null;
    setCapture(null);
    setText('');
  }

  async function createComment() {
    if (!ui || !capture || pending) 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.
    setPending(true);
    const receipt = await ui.comments.createFromCapture(capture, { text: text.trim() });
    setPending(false);
    if (report(receipt, 'Comment added.')) closeComposer();
  }

  async function showThread(commentId: string) {
    if (!ui?.comments.setActive(commentId)) {
      setStatus('The comment is no longer available.');
      return;
    }
    const result = await ui.comments.scrollTo(commentId);
    setStatus(
      result.success ? 'Showing the comment in the document.' : (result.reason ?? 'The comment could not be shown.'),
    );
  }

  if (!ui) return <aside aria-label='Comments'>Opening document...</aside>;

  return (
    <aside aria-labelledby='comments-heading'>
      <h2 id='comments-heading'>Comments</h2>
      <p>{commentState.listStatus === 'pending' ? 'Loading comments...' : `${threads.length} threads`}</p>

      <button
        disabled={selection.empty || capture !== null}
        onClick={openComposer}
        onMouseDown={captureSelection}
        ref={startCommentRef}
        type='button'
      >
        Comment on selection
      </button>

      {capture && (
        <form
          onSubmit={(event) => {
            event.preventDefault();
            void createComment();
          }}
        >
          <label>
            New comment
            <textarea
              autoFocus
              disabled={pending}
              onChange={(event) => setText(event.target.value)}
              rows={3}
              value={text}
            />
          </label>
          <button disabled={pending || text.trim().length === 0} type='submit'>
            Add comment
          </button>
          <button disabled={pending} onClick={closeComposer} type='button'>
            Cancel
          </button>
        </form>
      )}

      <ul>
        {threads.map((thread) => (
          <li aria-current={thread.id === commentState.activeId ? 'true' : undefined} key={thread.id}>
            <span>{thread.text || 'Comment without text'}</span>
            <button onClick={() => void showThread(thread.id)} type='button'>
              Show in document
            </button>
            <button
              onClick={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.');
              }}
              type='button'
            >
              {thread.status === 'resolved' ? 'Reopen' : 'Resolve'}
            </button>
          </li>
        ))}
      </ul>

      <p aria-live='polite' role='status'>
        {status}
      </p>
    </aside>
  );
}

```


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 [#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](/editor/custom-ui/selection-and-viewport)
when you also need to position an application-owned popover.

## Match the configured actions [#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](/editor/built-in-ui/comments#configure-comments) for the complete contract.

## Verify the workflow [#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](/editor/custom-ui/tracked-changes). 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](/editor/custom-ui/overview) and choose another workflow.
