# Build a custom review panel

> Render open tracked changes and let reviewers navigate, accept, or reject each one.



Configure [built-in tracked changes](/editor/track-changes) when the standard review controls fit your workflow. Build a
custom panel when your application needs to own the review queue, layout, or status messages.

This guide replaces only the review sidebar. Your application renders the queue and actions. SuperDoc keeps the queue
synchronized with the marks in the DOCX.

## Try the review workflow [#try-the-review-workflow]

The example keeps SuperDoc's toolbar and loads three changes across three short pages.

1. Use **Previous** and **Next** to move through the review queue.
2. Choose **Show in document** on any row to return to that change.
3. Accept or reject one change. Its row disappears and the open-change count decreases.

> **Live example: review changes from an application-owned panel**
>
> SuperDoc renders its toolbar and a three-page DOCX while the application renders the review queue. Previous, Next, and Show in document move between three real tracked changes. Accepting or rejecting one removes its row and decreases the open-change count. The panel observes `ui.trackChanges`; setting `ui.comments` to `false` removes the built-in comments and review sidebar without removing tracked changes from the document.


The panel follows the same loop as the comments example: observe state, render application markup, run an action, and
report its result.

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

Start from [Build your first custom control](/editor/custom-ui/controller-setup). Download the
[custom review fixture](/fixtures/custom-track-changes-workflow.docx) to your app's `public` directory as
`contract.docx`.

Vanilla needs toolbar, Editor, and panel mounts. If you use Vanilla, replace `index.html` with:

```html
<div id="toolbar"></div>

<main class="review-layout">
  <div id="editor" style="height: 70vh"></div>

  <aside aria-labelledby="review-heading">
    <h2 id="review-heading">Review changes</h2>
    <p id="change-count">Opening document...</p>
    <nav aria-label="Tracked change navigation">
      <button id="previous-change" type="button">Previous</button>
      <button id="next-change" type="button">Next</button>
    </nav>
    <ul id="change-list"></ul>
    <p id="review-status" role="status">Choose a change to review it.</p>
  </aside>
</main>

<script type="module" src="/src/main.ts"></script>

```

## Connect the panel [#connect-the-panel]

Replace the setup guide's Editor code:

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

```ts
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { CommandExecutionResult, TrackChangesItem, TrackChangesSlice } 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 review panel element: ${selector}`);
  return element;
}

const toolbar = getElement<HTMLDivElement>('#toolbar');
const changeCount = getElement<HTMLParagraphElement>('#change-count');
const changeList = getElement<HTMLUListElement>('#change-list');
const previousChange = getElement<HTMLButtonElement>('#previous-change');
const nextChange = getElement<HTMLButtonElement>('#next-change');
const reviewStatus = getElement<HTMLParagraphElement>('#review-status');

const editorUi = {
  comments: false,
  toolbar: { container: toolbar, responsiveTo: 'container' },
} satisfies UIConfig;
type Decision = 'accept' | 'reject';

let pendingDecision: { key: string; decision: Decision } | null = null;
// The occurrence this panel focused. `activeId` alone cannot tell a body row
// from a same-id footnote or header row, so the panel remembers which one it
// asked for and only trusts it while the controller still reports that id.
let activeRow: { id: string; key: string } | null = null;
let stopTrackChanges: (() => void) | null = null;
// The last snapshot from `observe()`. It is the complete review directory;
// the passive snapshot is bounded to the painted page window.
let lastChanges: TrackChangesSlice | null = null;

function decisionFailure(result: CommandExecutionResult): string | null {
  if (result === false) return 'The review decision is unavailable.';
  if (result === true || result.success) return null;
  return result.failure.message;
}

/** A row's exact occurrence: the id plus its story when the change is outside the body. */
function decisionTarget(change: TrackChangesItem): { id: string; story?: unknown } {
  const story = change.address?.story;
  return story ? { id: change.id, story } : { id: change.id };
}

/** Stable per-occurrence key. The same id can appear in the body and in a footnote or header. */
function rowKey(change: TrackChangesItem): string {
  return `${change.id}:${JSON.stringify(change.address?.story ?? null)}`;
}

/** Whether this row is the active occurrence, not merely a row sharing the active id. */
function isActiveRow(change: TrackChangesItem, activeId: string | null): boolean {
  if (change.id !== activeId) return false;
  return activeRow === null || activeRow.id !== activeId || activeRow.key === rowKey(change);
}

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  documentMode: 'suggesting',
  ui: editorUi,
  user: { name: 'Alex Rivera', email: 'alex@example.com' },
  onReady: ({ superdoc: readySuperDoc }) => {
    stopTrackChanges?.();
    const { ui } = readySuperDoc;

    const showChange = async (change: TrackChangesItem) => {
      if (pendingDecision) return;
      // The row's { id, story } pins the clicked occurrence for both focus and
      // reveal when the same id appears in the body and a footnote or header.
      const target = decisionTarget(change);
      if (!ui.trackChanges.setActive(target)) {
        reviewStatus.textContent = 'The tracked change is no longer available.';
        return;
      }
      activeRow = { id: change.id, key: rowKey(change) };
      if (lastChanges) render(lastChanges);

      const result = await ui.trackChanges.scrollTo(target);
      reviewStatus.textContent = result.success
        ? 'Showing the change in the document.'
        : (result.reason ?? 'The tracked change could not be shown.');
    };

    const navigate = async (direction: 'previous' | 'next') => {
      if (pendingDecision) return;
      // Navigation picks the occurrence, so the panel stops asserting its own.
      activeRow = null;
      const result =
        direction === 'previous' ? await ui.trackChanges.navigatePrevious() : await ui.trackChanges.navigateNext();
      reviewStatus.textContent = result.success
        ? `Showing the ${direction} change.`
        : 'No tracked change could be shown.';
    };

    const decideChange = async (decision: Decision, change: TrackChangesItem) => {
      if (pendingDecision) return;
      pendingDecision = { key: rowKey(change), decision };
      reviewStatus.textContent = decision === 'accept' ? 'Accepting change...' : 'Rejecting change...';
      if (lastChanges) render(lastChanges);

      // The async form resolves once the document operation settles, so a
      // late failure still clears the pending state and reaches the reader.
      const target = decisionTarget(change);
      const result =
        decision === 'accept' ? await ui.trackChanges.acceptAsync(target) : await ui.trackChanges.rejectAsync(target);

      pendingDecision = null;
      reviewStatus.textContent =
        decisionFailure(result) ?? (decision === 'accept' ? 'Change accepted.' : 'Change rejected.');
      if (lastChanges) render(lastChanges);
    };

    const render = (changes: TrackChangesSlice) => {
      lastChanges = changes;
      changeCount.textContent = changes.status === 'pending' ? 'Loading changes...' : `${changes.total} open changes`;
      changeList.replaceChildren();
      previousChange.disabled = changes.status === 'pending' || changes.total === 0 || pendingDecision !== null;
      nextChange.disabled = previousChange.disabled;

      for (const change of changes.items) {
        const row = document.createElement('li');
        const summary = document.createElement('span');
        const show = document.createElement('button');
        const accept = document.createElement('button');
        const reject = document.createElement('button');
        const detail =
          change.excerpt ?? change.insertedText ?? change.deletedText ?? change.formattingDeltaSummary ?? change.type;

        summary.textContent = `${detail}${change.author ? ` by ${change.author}` : ''}`;

        const active = isActiveRow(change, changes.activeId);
        const pending = pendingDecision?.key === rowKey(change) ? pendingDecision.decision : null;
        if (active) row.setAttribute('aria-current', 'true');

        show.type = 'button';
        show.textContent = active ? 'Showing' : 'Show in document';
        show.disabled = pendingDecision !== null;
        show.addEventListener('click', () => void showChange(change));

        accept.type = 'button';
        accept.textContent = pending === 'accept' ? 'Accepting...' : 'Accept';
        accept.disabled = pendingDecision !== null;
        accept.addEventListener('click', () => void decideChange('accept', change));

        reject.type = 'button';
        reject.textContent = pending === 'reject' ? 'Rejecting...' : 'Reject';
        reject.disabled = pendingDecision !== null;
        reject.addEventListener('click', () => void decideChange('reject', change));

        row.append(summary, show, accept, reject);
        changeList.append(row);
      }
    };

    stopTrackChanges = ui.trackChanges.observe(render);
    previousChange.addEventListener('click', () => void navigate('previous'));
    nextChange.addEventListener('click', () => void navigate('next'));
  },
  onContentError: ({ error }) => {
    reviewStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
  onException: ({ error }) => {
    reviewStatus.textContent = 'The document could not be opened.';
    console.error(error);
  },
});

window.addEventListener('beforeunload', () => {
  stopTrackChanges?.();
  superdoc.destroy();
});

```

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

```tsx
import { useEffect, useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { UIConfig } from 'superdoc';
import type { CommandExecutionResult, TrackChangesItem } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocTrackChanges, 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' };
type Decision = 'accept' | 'reject';

function decisionFailure(result: CommandExecutionResult): string | null {
  if (result === false) return 'The review decision is unavailable.';
  if (result === true || result.success) return null;
  return result.failure.message;
}

/** A row's exact occurrence: the id plus its story when the change is outside the body. */
function decisionTarget(change: TrackChangesItem): { id: string; story?: unknown } {
  const story = change.address?.story;
  return story ? { id: change.id, story } : { id: change.id };
}

/** Stable per-occurrence key. The same id can appear in the body and in a footnote or header. */
function rowKey(change: TrackChangesItem): string {
  return `${change.id}:${JSON.stringify(change.address?.story ?? null)}`;
}

type ActiveRow = { id: string; key: string };

/** Whether this row is the active occurrence, not merely a row sharing the active id. */
function isActiveRow(change: TrackChangesItem, activeId: string | null, activeRow: ActiveRow | null): boolean {
  if (change.id !== activeId) return false;
  return activeRow === null || activeRow.id !== activeId || activeRow.key === rowKey(change);
}

export default function App() {
  const [loadError, setLoadError] = useState<string | null>(null);

  return (
    <SuperDocUIProvider>
      <main className='review-layout'>
        <Editor onLoadError={setLoadError} />
        <ReviewPanel loadError={loadError} />
      </main>
    </SuperDocUIProvider>
  );
}

function Editor({ onLoadError }: { onLoadError: (message: string) => void }) {
  const setSuperDoc = useSetSuperDoc();

  return (
    <SuperDocEditor
      document='/contract.docx'
      documentMode='suggesting'
      onContentError={() => onLoadError('The document could not be opened.')}
      onException={() => onLoadError('The document could not be opened.')}
      onReady={({ superdoc }) => setSuperDoc(superdoc)}
      ui={editorUi}
      user={currentUser}
    />
  );
}

function ReviewPanel({ loadError }: { loadError: string | null }) {
  const ui = useSuperDocUI();
  const changes = useSuperDocTrackChanges();
  const [pendingKey, setPendingKey] = useState<string | null>(null);
  const [pendingDecision, setPendingDecision] = useState<Decision | null>(null);
  // The occurrence this panel focused. `activeId` alone cannot tell a body row
  // from a same-id footnote or header row, so the panel remembers which one it
  // asked for and only trusts it while the controller still reports that id.
  const [activeRow, setActiveRow] = useState<ActiveRow | null>(null);
  const [status, setStatus] = useState('Choose a change to review it.');

  useEffect(() => {
    if (loadError) setStatus(loadError);
  }, [loadError]);

  async function showChange(change: TrackChangesItem) {
    if (pendingKey) return;
    // The row's { id, story } pins the clicked occurrence for both focus and
    // reveal when the same id appears in the body and a footnote or header.
    const target = decisionTarget(change);
    if (!ui?.trackChanges.setActive(target)) {
      setStatus('The tracked change is no longer available.');
      return;
    }
    setActiveRow({ id: change.id, key: rowKey(change) });

    const result = await ui.trackChanges.scrollTo(target);
    setStatus(
      result.success
        ? 'Showing the change in the document.'
        : (result.reason ?? 'The tracked change could not be shown.'),
    );
  }

  async function navigate(direction: 'previous' | 'next') {
    if (!ui || pendingKey) return;
    // Navigation picks the occurrence, so the panel stops asserting its own.
    setActiveRow(null);
    const result =
      direction === 'previous' ? await ui.trackChanges.navigatePrevious() : await ui.trackChanges.navigateNext();
    setStatus(result.success ? `Showing the ${direction} change.` : 'No tracked change could be shown.');
  }

  async function decideChange(decision: Decision, change: TrackChangesItem) {
    if (!ui || pendingKey) return;
    setPendingKey(rowKey(change));
    setPendingDecision(decision);
    setStatus(decision === 'accept' ? 'Accepting change...' : 'Rejecting change...');

    // The async form resolves once the document operation settles, so a late
    // failure still clears the pending state and reaches the reader.
    const target = decisionTarget(change);
    const result =
      decision === 'accept' ? await ui.trackChanges.acceptAsync(target) : await ui.trackChanges.rejectAsync(target);

    setPendingKey(null);
    setPendingDecision(null);
    setStatus(decisionFailure(result) ?? (decision === 'accept' ? 'Change accepted.' : 'Change rejected.'));
  }

  return (
    <aside aria-labelledby='review-heading'>
      <h2 id='review-heading'>Review changes</h2>
      <p>
        {loadError
          ? 'Document unavailable'
          : changes.status === 'pending'
            ? 'Loading changes...'
            : `${changes.total} open changes`}
      </p>

      <nav aria-label='Tracked change navigation'>
        <button
          disabled={!ui || changes.status === 'pending' || changes.total === 0 || pendingKey !== null}
          onClick={() => void navigate('previous')}
          type='button'
        >
          Previous
        </button>
        <button
          disabled={!ui || changes.status === 'pending' || changes.total === 0 || pendingKey !== null}
          onClick={() => void navigate('next')}
          type='button'
        >
          Next
        </button>
      </nav>

      <ul>
        {changes.items.map((change) => {
          const detail =
            change.excerpt ?? change.insertedText ?? change.deletedText ?? change.formattingDeltaSummary ?? change.type;
          const active = isActiveRow(change, changes.activeId, activeRow);
          const pending = pendingKey === rowKey(change) ? pendingDecision : null;

          return (
            <li aria-current={active ? 'true' : undefined} key={rowKey(change)}>
              <span>{`${detail}${change.author ? ` by ${change.author}` : ''}`}</span>
              <button disabled={pendingKey !== null} onClick={() => void showChange(change)} type='button'>
                {active ? 'Showing' : 'Show in document'}
              </button>
              <button disabled={pendingKey !== null} onClick={() => void decideChange('accept', change)} type='button'>
                {pending === 'accept' ? 'Accepting...' : 'Accept'}
              </button>
              <button disabled={pendingKey !== null} onClick={() => void decideChange('reject', change)} type='button'>
                {pending === 'reject' ? 'Rejecting...' : 'Reject'}
              </button>
            </li>
          );
        })}
      </ul>

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

```


Both versions use one domain handle:

* Set `ui.comments` to `false` to remove SuperDoc's comments and review sidebar without removing review data from the
  DOCX.
* Use `observe()` or `useSuperDocTrackChanges()` to keep the list and active change current.
* Run `setActive()` with `scrollTo()`, or `navigatePrevious()` and `navigateNext()`, to move between document marks.
* Run `acceptAsync()` or `rejectAsync()` with the row's `{ id, story }` and await the settled result. Keep the panel
  pending until it resolves, then report success or the failure message.

## Keep decisions synchronized [#keep-decisions-synchronized]

A decision can still fail after it is routed because the document is read-only, the change no longer exists, or the
Editor's interaction policy blocks it. Awaiting the async form is what lets the panel clear its pending state and show
that failure. The sync `accept()` and `reject()` return the routed result before the operation settles, so a panel that
waits for the row to disappear can lock forever. See [Review tracked changes](/editor/track-changes) for modes and
interaction policy.

Pass the row's `story` with its `id`. The same tracked-change id can appear in the body and in a footnote or header, and
the story is what pins the decision to the occurrence the reviewer clicked.

Accepting or rejecting changes updates the open document. It does not save the DOCX. Follow
[Load and save documents](/editor/load-and-save-documents) when the review result must persist.

## Verify the panel [#verify-the-panel]

Run the project. Move to a change on another page, then accept or reject it. The document should move to the selected
mark. A successful decision should remove one row and decrease the count.

Use [Document API tracked changes](/document-api/tracked-changes) when application code reviews known change IDs without
driving an Editor panel.

Return to the [Custom UI overview](/editor/custom-ui/overview) to choose another workflow.
