diff --git a/.github/workflows/ci-examples.yml b/.github/workflows/ci-examples.yml
index 3c94c34a9c..a7d6e46809 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, comments]
+ surface: [toolbar, comments, track-changes]
framework: [vanilla]
steps:
- name: Restore workspace
diff --git a/examples/README.md b/examples/README.md
index e45968bfec..89c8d97db4 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -41,6 +41,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
|---------|------|
| [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) |
+| [track-changes/vanilla](./editor/custom-ui/track-changes/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/track-changes) |
### Theming
diff --git a/examples/editor/custom-ui/track-changes/vanilla/README.md b/examples/editor/custom-ui/track-changes/vanilla/README.md
new file mode 100644
index 0000000000..0284753c05
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/README.md
@@ -0,0 +1,28 @@
+# Custom UI: vanilla tracked changes
+
+A custom SuperDoc tracked-changes review panel in plain TypeScript. Single file, no framework, copy-paste into your own app.
+
+## What this teaches
+
+- `ui.trackChanges.observe(snapshot => ...)` to render the review list from a single subscription.
+- `ui.trackChanges.accept(id)` / `.reject(id)` for per-change decisions.
+- `ui.trackChanges.acceptAll()` / `.rejectAll()` for bulk decisions.
+- `ui.trackChanges.next()` / `.previous()` / `.scrollTo(id)` for navigation, plus the live `activeId` so the panel highlights the change under the cursor.
+- `ui.document.observe` + `setMode('editing' | 'suggesting')` so the user can toggle between editing normally and recording tracked changes.
+- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.
+
+## Run
+
+```bash
+pnpm install
+pnpm dev
+```
+
+Switch to **Suggest** mode and edit the document. Each insertion or deletion becomes a tracked change in the right-hand panel. Accept and reject decisions round-trip through Word.
+
+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 > Track changes](https://docs.superdoc.dev/editor/custom-ui/track-changes)
+- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`comments/vanilla`](../../comments/vanilla)
diff --git a/examples/editor/custom-ui/track-changes/vanilla/index.html b/examples/editor/custom-ui/track-changes/vanilla/index.html
new file mode 100644
index 0000000000..507432ed46
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ SuperDoc Custom UI: vanilla tracked changes
+
+
+
+
+
+
diff --git a/examples/editor/custom-ui/track-changes/vanilla/package.json b/examples/editor/custom-ui/track-changes/vanilla/package.json
new file mode 100644
index 0000000000..b0a86a05b2
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@superdoc-examples/custom-ui-track-changes-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/track-changes/vanilla/public/test_file.docx b/examples/editor/custom-ui/track-changes/vanilla/public/test_file.docx
new file mode 100644
index 0000000000..b1b8c8f5a7
Binary files /dev/null and b/examples/editor/custom-ui/track-changes/vanilla/public/test_file.docx differ
diff --git a/examples/editor/custom-ui/track-changes/vanilla/src/main.ts b/examples/editor/custom-ui/track-changes/vanilla/src/main.ts
new file mode 100644
index 0000000000..57cf47ad94
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/src/main.ts
@@ -0,0 +1,140 @@
+/**
+ * Custom tracked-changes review panel (vanilla TypeScript), single file.
+ *
+ * Patterns to notice:
+ *
+ * - `ui.trackChanges.observe(snapshot => ...)` drives the panel
+ * list AND the bulk-action enable state from one subscription.
+ * The snapshot carries `items`, `total`, and `activeId`.
+ * - `ui.trackChanges.accept(id) / .reject(id)` resolve one change.
+ * `.acceptAll() / .rejectAll()` are the bulk verbs.
+ * `.next() / .previous() / .scrollTo(id)` drive navigation;
+ * `activeId` reflects whichever change is under the cursor.
+ * - `ui.document.observe + setMode('editing' | 'suggesting')` lets
+ * the user toggle between editing normally and recording each
+ * edit as a tracked change. Without Suggest mode, the panel is
+ * empty by design.
+ * - `ui.createScope()` collects every subscription so the whole
+ * surface tears down cleanly on `ui.destroy()`.
+ *
+ * `trackChanges.replacements: 'independent'` tells the engine to
+ * surface a typed-over selection as two separate review items
+ * (insert + delete) instead of one composite. Matches what most
+ * review UIs want to render.
+ */
+
+import { SuperDoc } from 'superdoc';
+import { createSuperDocUI, type TrackChangesSlice } from 'superdoc/ui';
+import 'superdoc/style.css';
+import './style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: '/test_file.docx',
+ documentMode: 'suggesting',
+ user: { name: 'Alex Rivera', email: 'alex@example.com' },
+ modules: { trackChanges: { replacements: 'independent' } },
+});
+
+const ui = createSuperDocUI({ superdoc });
+const scope = ui.createScope();
+
+const modeEl = document.querySelector('#mode-toggle')!;
+const list = document.querySelector('#changes')!;
+const prevBtn = document.querySelector('#prev')!;
+const nextBtn = document.querySelector('#next')!;
+const acceptAllBtn = document.querySelector('#accept-all')!;
+const rejectAllBtn = document.querySelector('#reject-all')!;
+
+// Edit / Suggest toggle. ui.document carries `mode`; setMode flips
+// the routed editor and fires the same `document-mode-change` event
+// the React wrapper consumes.
+modeEl.innerHTML = `
+ Edit
+ Suggest
+`;
+modeEl.addEventListener('click', (e) => {
+ const t = (e.target as HTMLElement).closest('button[data-mode]');
+ if (!t) return;
+ ui.document.setMode(t.dataset.mode as 'editing' | 'suggesting');
+});
+scope.add(
+ ui.document.observe((doc) => {
+ modeEl.querySelectorAll('button[data-mode]').forEach((b) => {
+ b.classList.toggle('active', b.dataset.mode === doc.mode);
+ b.disabled = !doc.ready;
+ });
+ }),
+);
+
+// Bulk action wiring.
+prevBtn.addEventListener('click', () => {
+ const id = ui.trackChanges.previous();
+ if (id) void ui.trackChanges.scrollTo(id);
+});
+nextBtn.addEventListener('click', () => {
+ const id = ui.trackChanges.next();
+ if (id) void ui.trackChanges.scrollTo(id);
+});
+acceptAllBtn.addEventListener('click', () => ui.trackChanges.acceptAll());
+rejectAllBtn.addEventListener('click', () => ui.trackChanges.rejectAll());
+
+// One subscription drives the list AND the bulk-action enable state.
+scope.add(
+ ui.trackChanges.observe((snapshot) => render(snapshot)),
+);
+
+function render(snapshot: TrackChangesSlice): void {
+ const empty = snapshot.items.length === 0;
+ prevBtn.disabled = empty;
+ nextBtn.disabled = empty;
+ acceptAllBtn.disabled = empty;
+ rejectAllBtn.disabled = empty;
+
+ list.innerHTML = '';
+ if (empty) {
+ const li = document.createElement('li');
+ li.className = 'empty';
+ li.textContent = 'No tracked changes. Switch to Suggest mode and edit the document.';
+ list.appendChild(li);
+ return;
+ }
+
+ for (const { id, change } of snapshot.items) {
+ const kind = change.type === 'insert' ? 'insertion' : change.type === 'delete' ? 'deletion' : 'format';
+ const author = change.author ?? change.authorEmail ?? 'Unknown';
+ const li = document.createElement('li');
+ li.className = `card${id === snapshot.activeId ? ' active' : ''}`;
+ li.dataset.id = id;
+ li.innerHTML = `
+ ${kind}
+ ${escape(author)}
+ ${change.excerpt ? `"${escape(change.excerpt)}"
` : ''}
+
+ Accept
+ Reject
+
+ `;
+ li.addEventListener('click', () => void ui.trackChanges.scrollTo(id));
+ li.querySelector('[data-action="accept"]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ ui.trackChanges.accept(id);
+ });
+ li.querySelector('[data-action="reject"]')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ ui.trackChanges.reject(id);
+ });
+ list.appendChild(li);
+ }
+}
+
+function escape(s: string): string {
+ return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!);
+}
+
+const teardown = () => {
+ ui.destroy();
+ superdoc.destroy();
+};
+window.addEventListener('beforeunload', teardown);
+if (import.meta.hot) import.meta.hot.dispose(teardown);
diff --git a/examples/editor/custom-ui/track-changes/vanilla/src/style.css b/examples/editor/custom-ui/track-changes/vanilla/src/style.css
new file mode 100644
index 0000000000..de3678f3bc
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/src/style.css
@@ -0,0 +1,51 @@
+:root {
+ --bg: #fff;
+ --bg-muted: #f7f7f8;
+ --border: #e4e4e7;
+ --text: #18181b;
+ --text-muted: #71717a;
+ --accent: #2563eb;
+ --accent-soft: #eff6ff;
+ --insert: #16a34a;
+ --insert-soft: #dcfce7;
+ --delete: #dc2626;
+ --delete-soft: #fee2e2;
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
+button { font: inherit; cursor: pointer; }
+
+.app { display: grid; grid-template-columns: 1fr 340px; height: 100vh; }
+.editor-area { overflow: auto; padding: 8px 12px; }
+.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }
+
+.mode { display: flex; gap: 4px; margin-bottom: 8px; }
+.mode button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
+.mode button.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
+.mode button:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }
+
+.sidebar-head { margin-bottom: 12px; }
+.sidebar-head h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 8px; }
+.bulk { display: flex; flex-wrap: wrap; gap: 4px; }
+.bulk button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
+.bulk button.primary { border-color: var(--accent); color: var(--accent); }
+.bulk button:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.changes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+.changes .empty { color: var(--text-muted); font-size: 12px; }
+.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); cursor: pointer; }
+.card:hover { border-color: var(--accent); }
+.card.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
+
+.kind { display: inline-block; font-size: 11px; padding: 2px 6px; border-radius: 3px; text-transform: capitalize; margin-bottom: 4px; }
+.kind.insertion { background: var(--insert-soft); color: var(--insert); }
+.kind.deletion { background: var(--delete-soft); color: var(--delete); }
+.kind.format { background: var(--bg-muted); color: var(--text-muted); }
+
+.author { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
+.excerpt { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; }
+.actions { display: flex; gap: 6px; margin-top: 8px; }
+.actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
+.actions button.primary { border-color: var(--insert); color: var(--insert); }
+.actions button.danger { border-color: var(--delete); color: var(--delete); }
diff --git a/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json b/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json
new file mode 100644
index 0000000000..6d42803dda
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts b/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts
new file mode 100644
index 0000000000..c049f46e10
--- /dev/null
+++ b/examples/editor/custom-ui/track-changes/vanilla/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({});
diff --git a/examples/manifest.json b/examples/manifest.json
index 246264f471..8d188f9acf 100644
--- a/examples/manifest.json
+++ b/examples/manifest.json
@@ -139,6 +139,16 @@
"docs": "https://docs.superdoc.dev/editor/custom-ui/comments",
"ci": true
},
+ {
+ "id": "editor-custom-ui-track-changes-vanilla",
+ "title": "Custom tracked-changes panel (vanilla)",
+ "category": "Editor",
+ "surface": "Custom UI",
+ "sourceRepo": "superdoc-dev/superdoc",
+ "sourcePath": "examples/editor/custom-ui/track-changes/vanilla",
+ "docs": "https://docs.superdoc.dev/editor/custom-ui/track-changes",
+ "ci": true
+ },
{
"id": "editor-spell-check-typo-js",
"title": "Spell check with Typo.js",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 96cc6f5d94..a739e4bda7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1986,6 +1986,19 @@ importers:
specifier: npm:rolldown-vite@7.3.1
version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ examples/editor/custom-ui/track-changes/vanilla:
+ dependencies:
+ superdoc:
+ specifier: workspace:*
+ version: link:../../../../../packages/superdoc
+ devDependencies:
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ vite:
+ specifier: npm:rolldown-vite@7.3.1
+ version: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+
examples/editor/spell-check/language-tool-self-hosted:
dependencies:
react:
@@ -2295,7 +2308,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue:
specifier: 3.5.32
version: 3.5.32(typescript@5.9.3)
@@ -2320,7 +2333,7 @@ importers:
version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: 'catalog:'
- version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
+ version: 3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
concurrently:
specifier: 'catalog:'
version: 9.2.1
@@ -2344,7 +2357,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
+ version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
packages/document-api: {}
@@ -33252,25 +33265,6 @@ snapshots:
vite: rolldown-vite@7.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
- dependencies:
- '@ampproject/remapping': 2.3.0
- '@bcoe/v8-coverage': 1.0.2
- ast-v8-to-istanbul: 0.3.12
- debug: 4.4.3(supports-color@5.5.0)
- istanbul-lib-coverage: 3.2.2
- istanbul-lib-report: 3.0.1
- istanbul-lib-source-maps: 5.0.6
- istanbul-reports: 3.2.0
- magic-string: 0.30.21
- magicast: 0.3.5
- std-env: 3.10.0
- test-exclude: 7.0.2
- tinyrainbow: 2.0.0
- vitest: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.4)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
- transitivePeerDependencies:
- - supports-color
-
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@22.19.2)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0