diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml
index ef83d02edf..3c94c34a9c 100644
--- a/.github/workflows/ci-examples.yml
+++ b/.github/workflows/ci-examples.yml
@@ -200,7 +200,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- surface: [toolbar]
+ surface: [toolbar, comments]
framework: [vanilla]
steps:
- name: Restore workspace
diff --git a/examples/README.md b/examples/README.md
index 61130e8688..e45968bfec 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -40,6 +40,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
| Example | Docs |
|---------|------|
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
+| [comments/vanilla](./editor/custom-ui/comments/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/comments) |
### Theming
diff --git a/examples/editor/custom-ui/comments/vanilla/README.md b/examples/editor/custom-ui/comments/vanilla/README.md
new file mode 100644
index 0000000000..9e432f295d
--- /dev/null
+++ b/examples/editor/custom-ui/comments/vanilla/README.md
@@ -0,0 +1,25 @@
+# Custom UI: vanilla comments
+
+A custom SuperDoc comments sidebar in plain TypeScript. Single file, no framework, copy-paste into your own app.
+
+## What this teaches
+
+- `ui.selection.capture()` to freeze the editor selection at the moment the user clicks "Add comment", so the anchor survives the textarea taking focus.
+- `ui.comments.createFromCapture(capture, { text })` to anchor the new comment against that frozen snapshot, not the live (now-empty) selection.
+- `ui.comments.observe(snapshot => ...)` to render the sidebar list from a single subscription.
+- Resolve, reopen, and reply per card via `ui.comments.resolve / .reopen / .reply`.
+- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.
+
+## Run
+
+```bash
+pnpm install
+pnpm dev
+```
+
+The `predev` script builds the local `superdoc` workspace package so type imports resolve from `dist/`. From a published `npm` install this step is unnecessary.
+
+## See also
+
+- Docs: [Custom UI > Comments](https://docs.superdoc.dev/editor/custom-ui/comments)
+- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`track-changes/vanilla`](../../track-changes/vanilla)
diff --git a/examples/editor/custom-ui/comments/vanilla/index.html b/examples/editor/custom-ui/comments/vanilla/index.html
new file mode 100644
index 0000000000..6f3ebe1936
--- /dev/null
+++ b/examples/editor/custom-ui/comments/vanilla/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ SuperDoc Custom UI: vanilla comments
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/editor/custom-ui/comments/vanilla/package.json b/examples/editor/custom-ui/comments/vanilla/package.json
new file mode 100644
index 0000000000..53efe94af2
--- /dev/null
+++ b/examples/editor/custom-ui/comments/vanilla/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@superdoc-examples/custom-ui-comments-vanilla",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "predev": "pnpm --filter superdoc build",
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "superdoc": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "catalog:",
+ "vite": "catalog:"
+ }
+}
diff --git a/examples/editor/custom-ui/comments/vanilla/public/test_file.docx b/examples/editor/custom-ui/comments/vanilla/public/test_file.docx
new file mode 100644
index 0000000000..b1b8c8f5a7
Binary files /dev/null and b/examples/editor/custom-ui/comments/vanilla/public/test_file.docx differ
diff --git a/examples/editor/custom-ui/comments/vanilla/src/main.ts b/examples/editor/custom-ui/comments/vanilla/src/main.ts
new file mode 100644
index 0000000000..71186b3f00
--- /dev/null
+++ b/examples/editor/custom-ui/comments/vanilla/src/main.ts
@@ -0,0 +1,154 @@
+/**
+ * Custom comments sidebar (vanilla TypeScript), single file.
+ *
+ * The load-bearing pattern is `ui.selection.capture()`:
+ *
+ * The user selects text, clicks Add comment, the textarea takes
+ * focus, and the editor's live selection visually clears. A
+ * composer that read the live selection at submit time would see
+ * `null` and refuse the create. `capture()` returns a frozen
+ * snapshot at the moment the composer opens, so
+ * `comments.createFromCapture(capture, { text })` anchors the new
+ * comment against the original selection regardless of where focus
+ * moves afterwards.
+ *
+ * The other patterns:
+ *
+ * - `ui.comments.observe(snapshot => ...)` drives the sidebar list
+ * from a single subscription. No event-wrapped shape.
+ * - `ui.comments.resolve / .reopen / .reply` route through the
+ * same Document API that powers DOCX import / export, so changes
+ * made here round-trip through Word.
+ * - `ui.createScope()` collects every subscription so the whole
+ * surface tears down cleanly on `ui.destroy()`.
+ */
+
+import { SuperDoc } from 'superdoc';
+import { createSuperDocUI, type CommentsSlice, type SelectionCapture } from 'superdoc/ui';
+import 'superdoc/style.css';
+import './style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: '/test_file.docx',
+ documentMode: 'editing',
+ user: { name: 'Alex Rivera', email: 'alex@example.com' },
+ modules: { comments: false }, // disable built-in comments UI; we render our own
+});
+
+const ui = createSuperDocUI({ superdoc });
+const scope = ui.createScope();
+
+// DOM handles the example writes into.
+const addBtn = document.querySelector('#add-comment')!;
+const composerMount = document.querySelector('#composer-mount')!;
+const list = document.querySelector('#comments')!;
+
+// Add-comment button is enabled only when the editor has a real
+// positional selection. `ui.selection.observe` fires once
+// synchronously and again on every selection change.
+scope.add(
+ ui.selection.observe((sel) => {
+ addBtn.disabled = sel.empty || sel.selectionTarget == null;
+ }),
+);
+
+// The composer mounts only when the user clicks Add comment, and
+// captures the selection at that moment.
+addBtn.addEventListener('click', () => openComposer());
+
+function openComposer(): void {
+ // Capture the selection NOW. The textarea will steal focus next.
+ const capture = ui.selection.capture();
+ if (!capture) return;
+
+ composerMount.innerHTML = `
+
+
${capture.quotedText ? `"${escape(capture.quotedText)}"` : 'No text selection'}
+
+
+
+
+
+
+ `;
+
+ const ta = composerMount.querySelector('textarea')!;
+ const postBtn = composerMount.querySelector('button[data-action="post"]')!;
+ const cancelBtn = composerMount.querySelector('button[data-action="cancel"]')!;
+
+ ta.addEventListener('input', () => {
+ postBtn.disabled = ta.value.trim().length === 0;
+ });
+ ta.focus();
+
+ cancelBtn.addEventListener('click', closeComposer);
+ postBtn.addEventListener('click', () => post(capture, ta.value));
+}
+
+function closeComposer(): void {
+ composerMount.innerHTML = '';
+}
+
+function post(capture: SelectionCapture, raw: string): void {
+ const text = raw.trim();
+ if (!text) return;
+ const receipt = ui.comments.createFromCapture(capture, { text });
+ if (!receipt.success) {
+ console.error('[comments] create failed', receipt);
+ return;
+ }
+ closeComposer();
+}
+
+// Render the sidebar from the comments slice. One subscription, the
+// whole list re-renders when the snapshot changes. For a real product
+// you'd diff DOM; this example optimises for clarity.
+scope.add(
+ ui.comments.observe((snapshot) => renderComments(snapshot)),
+);
+
+function renderComments(snapshot: CommentsSlice): void {
+ list.innerHTML = '';
+ if (snapshot.items.length === 0) {
+ const empty = document.createElement('li');
+ empty.className = 'empty';
+ empty.textContent = 'No comments yet. Select text and click Add comment.';
+ list.appendChild(empty);
+ return;
+ }
+ for (const c of snapshot.items) {
+ if (c.parentCommentId) continue; // replies render under their root, below
+ const li = document.createElement('li');
+ li.className = `card${c.status === 'resolved' ? ' resolved' : ''}`;
+ li.innerHTML = `
+