Skip to main content
useSuperDocComments() gives you the live comments feed. ui.selection.capture() freezes the selection so a composer textarea can take focus without losing the anchor. ui.comments.createFromCapture(capture, { text }) posts the comment.

A minimal comments list

import { useSuperDocComments, useSuperDocUI } from 'superdoc/ui/react';

export function CommentsList() {
  const { items } = useSuperDocComments();
  const ui = useSuperDocUI();

  return (
    <div>
      {items.map((c) => (
        <article key={c.id}>
          <header>
            <strong>{c.creatorName ?? 'Unknown'}</strong>
          </header>
          <p>{c.text}</p>
          <button onClick={() => ui?.comments.resolve(c.id)}>Resolve</button>
        </article>
      ))}
    </div>
  );
}
items is the array sourced from editor.doc.comments.list(). Each item is a DiscoveryItem<CommentDomain> with id, text, creatorName, creatorEmail, createdTime, parentCommentId, status, target, anchoredText.

Disable the built-in comments UI

When you’re rendering your own panel, turn off SuperDoc’s so the two don’t overlap.
<SuperDocEditor
  document="/contract.docx"
  modules={{ comments: false }}
  hideToolbar
  contained
  onReady={({ superdoc }) => setSuperDoc(superdoc)}
/>
Imported comments still flow through the engine on export and import. The flag turns off the rendered UI, not the data.

Add a comment from a selection

Two paths. Pick based on whether your composer takes focus from the editor.

When the user clicks “Comment” with the selection still active

import { useSuperDocSelection, useSuperDocUI } from 'superdoc/ui/react';

function AddCommentButton() {
  const ui = useSuperDocUI();
  const selection = useSuperDocSelection();

  return (
    <button
      disabled={selection.empty || !selection.target}
      onClick={() => ui?.comments.createFromSelection({ text: 'Looks good' })}
    >
      Comment
    </button>
  );
}
createFromSelection reads the live editor selection. If your button doesn’t steal focus, the selection is still there when the click handler runs.

When you open a composer with a textarea

A textarea takes focus. The editor selection clears. By the time the user presses “Post”, the live selection is gone. Capture it first.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { SelectionCapture } from 'superdoc/ui';
import { useSuperDocUI } from 'superdoc/ui/react';

export function CommentComposer({ onPosted }: { onPosted(): void }) {
  const ui = useSuperDocUI();
  const [text, setText] = useState('');
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);

  // Freeze the selection at mount. Holds across focus changes.
  const captured: SelectionCapture | null = useMemo(
    () => ui?.selection.capture() ?? null,
    [ui],
  );

  useEffect(() => {
    textareaRef.current?.focus();
  }, []);

  const post = () => {
    if (!ui || !captured) return;
    const receipt = ui.comments.createFromCapture(captured, { text: text.trim() });
    if (receipt.success) onPosted();
  };

  return (
    <div>
      {captured?.quotedText ? <blockquote>{captured.quotedText}</blockquote> : null}
      <textarea
        ref={textareaRef}
        rows={3}
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Write a comment..."
      />
      <button onClick={post} disabled={!captured || !text.trim()}>
        Comment
      </button>
    </div>
  );
}
captured.target is the same shape editor.doc.comments.create({ target }) accepts. quotedText gives you the anchored text for previewing in the composer. See the selection capture example for a runnable vanilla version: open a composer, move focus to a textarea, and post against the original selection.

When the built-in bubble opens your composer

If you keep the built-in floating comment bubble but render your own composer, listen for the pending comment event. The event carries pendingSelection, captured before SuperDoc inserts the pending mark, so you do not need to continuously cache the last non-empty selection.
import { useEffect, useState } from 'react';
import type { SuperDocCommentsUpdatePayload } from 'superdoc';
import type { SelectionInfo } from 'superdoc/ui';

const [pendingSelection, setPendingSelection] = useState<SelectionInfo | null>(null);

useEffect(() => {
  if (!superdoc) return;

  const handleCommentsUpdate = ({ type, pendingSelection }: SuperDocCommentsUpdatePayload) => {
    if (type === 'pending') setPendingSelection(pendingSelection ?? null);
  };

  superdoc.on('comments-update', handleCommentsUpdate);
  return () => superdoc.off('comments-update', handleCommentsUpdate);
}, [superdoc]);

function postComment(text: string) {
  if (!ui || !pendingSelection) return;
  return ui.comments.createFromCapture(pendingSelection, { text: text.trim() });
}
pendingSelection is a Document API selection snapshot. It has the target that createFromCapture needs, but it can be null for PDF selections, mismatched documents, or selections with no text anchor.

Resolve and reopen

ui.comments.resolve(commentId);
ui.comments.reopen(commentId);
ui.comments.delete(commentId);
All three return a Document API receipt. The next snapshot from useSuperDocComments() reflects the change.

Activate a comment highlight without scrolling

Use setActive when your UI handles scrolling and only needs SuperDoc to highlight the comment.
scrollYourSidebarOrEditor(commentId);
const activated = ui.comments.setActive(commentId);
Pass null to clear the highlight. Reply ids activate their anchored thread root, unknown ids return false, and a later editor selection change can clear the highlight because setActive does not move the caret.

Scroll to a comment

ui.comments.scrollTo(commentId);
Scrolls the editor viewport to the comment’s anchor. Body-scoped today: the comment-address contract carries no story field, so a comment anchored in a header, footer, or note doesn’t navigate through this call. For now, scroll those manually via ui.viewport.scrollIntoView({ target: ... }) once you’ve resolved the right address yourself.

Threads and replies

Comments form threads via parentCommentId. Each item from useSuperDocComments() carries one if it’s a reply; group your sidebar by parentCommentId to render thread roots with their replies stacked underneath.
const { items } = useSuperDocComments();
const roots = items.filter((c) => !c.parentCommentId);
const repliesByParent = items.reduce((map, c) => {
  if (c.parentCommentId) {
    const list = map.get(c.parentCommentId) ?? [];
    list.push(c);
    map.set(c.parentCommentId, list);
  }
  return map;
}, new Map<string, typeof items>());
To post a reply, call ui.comments.reply(parentCommentId, { text }). The reply inherits the parent’s anchor, so you don’t pass a target. Empty or whitespace-only text returns a NO_OP receipt instead of hitting the API.
import { useSuperDocUI } from 'superdoc/ui/react';

function ReplyComposer({ parent }: { parent: { id: string } }) {
  const ui = useSuperDocUI();
  const [text, setText] = useState('');

  const post = () => {
    if (!ui || !text.trim()) return;
    const receipt = ui.comments.reply(parent.id, { text: text.trim() });
    if (receipt.success) setText('');
  };

  return (
    <>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={post} disabled={!ui || !text.trim()}>Reply</button>
    </>
  );
}
The next snapshot from useSuperDocComments() includes the reply, threaded under the parent via parentCommentId. The reference demo’s ActivitySidebar ships this pattern with focus management and Ctrl/Cmd+Enter to post.

Theming

Comment cards, body text, timestamps, and active states are themable via --sd-ui-comments-* CSS variables. See Theming overview and Custom themes for the full token list.

Trade-offs

  • useSuperDocComments returns a memoized snapshot. Re-renders happen only when items, total, or activeIds change.
  • createFromSelection returns { success: false, failure: { code: 'NO_OP' } } when there’s no selection target. Guard with selection.empty or selection.target == null.
  • createFromCapture stays valid as long as the captured snapshot’s blocks still exist. If the user deleted the anchored text between capture and post, the post fails with INVALID_TARGET.
  • Multi-paragraph anchors export as one commentRangeStart / commentRangeEnd pair per comment id, conformant with ECMA-376 §17.13.4.