Customization

Tracked changes

Customize DOCX editor tracked changes: review sidebar, tracked change balloons, author colors, text marks, and review controls.

Choose a review sidebar or tracked change balloons beside the changed text. Then set author colors, text marks, and the appearance of the review controls. Each section includes a contract preview and React and Vue examples.

Choose where tracked changes appear

The review module shows comments and tracked changes in a review pane beside the page. Four settings change that display. You can combine them, and you can change them while the editor runs.

SettingValuesDefaultWhat it controls
revisionsInpane, balloonspaneWhere tracked changes open.
commentMarkersinitials, iconiconHow a collapsed comment marker looks.
openingauto, manualautoWhen the review pane opens by itself.
overflowfloat, shrinkPage, scrollfloatWhat the open pane does when it does not fit the page.

The settings belong to one editor instance. The editor never saves them into the document or shares them with other participants. They stay in force when you load another document. Review controls require an EigenPal Pro License.

Understand the default display

With no settings, the review pane lists comments and tracked changes as cards beside the text that they belong to. The pane opens when a document with review items loads, and when you make a tracked change while it is closed. A collapsed comment marker shows the comment icon. Set commentMarkers: 'initials' to show the thread author’s initials. Clicking text with an open comment opens the review pane. Selecting text leaves the pane closed.

The following example mounts the default display. DocxEditorReview renders the pane, its cards, its markers, and its tracked change balloons, so every example on this page includes it.

import { DocxEditor } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';

const modules = [reviewModule()];

export function ReviewEditor({ bytes }: { bytes: Uint8Array }) {
  return (
    <DocxEditor.Root document={bytes} modules={modules} author="Jess Lin">
      <DocxEditor.Toolbar />
      <DocxEditor.Viewport style={{ height: 520 }}>
        <DocxEditor.Content />
        <DocxEditorReview />
      </DocxEditor.Viewport>
    </DocxEditor.Root>
  );
}

Set the starting display

Pass the settings to reviewModule through pane. Omit a setting to keep its default. Create the module list once, outside the component, so that the editor does not receive a new module on each render.

import { reviewModule, type ReviewPaneOptions } from '@docx-editor.dev/pro/react';

const pane: ReviewPaneOptions = { revisionsIn: 'balloons', commentMarkers: 'initials' };
const modules = [reviewModule({ pane })];

The editor checks each value. An unknown setting or value throws a TypeError when you call reviewModule, so a misspelled setting does not go unnoticed.

Use tracked change balloons

With revisionsIn: 'balloons', the review pane lists comments only. Each tracked change opens in a tracked change balloon at its text. These actions open a tracked change balloon:

  • A click on the tracked change.
  • Next Change or Previous Change.
  • A call to setActiveReviewItem(key, { announce: true }).

Moving the caret into a change does not open its tracked change balloon. The tracked change balloon shows the author, the date, and the changed words. It lists replies, has a reply field, and has Accept and Reject buttons. A deletion followed by an insertion from the same author appears as one replacement.

Escape, typing in the document, or a click elsewhere on the page closes the tracked change balloon. A tracked change balloon that Next Change opens takes the keyboard focus, so the user can press Tab to reach Accept and Reject. Tracked change balloons open only in All Markup.

With tracked change balloons, a document that has tracked changes and no comments does not open the pane. Switching to 'balloons' closes an open pane that has nothing left to list.

const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];

To style the tracked change balloon or to replace it with your own component, see Style tracked change balloons.

Choose the comment marker style

A collapsed comment marker marks a comment thread beside the page, for example while the pane is closed.

  • 'initials': a badge with the thread author's initials in the author's color. A small count shows the number of replies, and a check mark replaces the initials when the thread is resolved. Each badge is a miniature of its thread, so the user can see who commented without opening the pane.
  • 'icon': the comment icon. Use it when initials do not identify your users, such as for anonymous reviewers.

The DOCX editor shows at most three characters from a comment's saved initials. If saved initials are absent, it uses the first letters of the author's first two name parts. An empty author name shows ?.

The built-in initials badge has a maximum width of 36 pixels. Long initials stay inside the badge, while the reply count stays outside it. Dark mode preserves the badge border when you move the pointer over the marker.

const modules = [reviewModule({ pane: { commentMarkers: 'icon' } })];

The author color comes from the author color palette or from a DocxEditor.AuthorStyle declaration. For more information, see Tracked changes. To draw your own marker, pass an icon function to DocxEditorReview.Markers. That function takes precedence over both values. For more information, see Comments and review cards.

Keep the pane closed until the user opens it

With opening: 'manual', the pane stays closed until your application or the user opens it. The markers still show where each comment is. Next Change and Previous Change move through the changes without opening the pane.

const modules = [reviewModule({ pane: { opening: 'manual' } })];

The user opens the pane with the Comments & Changes toolbar button (DocxEditor.Toolbar.Comments). To open it from your application, call setPaneOpen(true) from useReview(), or run the toggleReviewPane command. Read the open state from paneOpen in useReview(), or from snapshot().reviewPaneOpen.

Choose what happens when the pane does not fit

The open pane needs a 316-pixel card column beside the page. overflow decides what happens when the viewport is too narrow for the page and the column at the page's zoom.

ValueThe pageThe review column
'float'Keeps its size.Changes to a marker strip. The open card floats over the page.
'shrinkPage'Shrinks under a fit zoom, down to minZoom.Stays full width beside the page while a scale exists that fits.
'scroll'Keeps the size it has while the pane is closed.Stays full width. The viewport scrolls sideways to reach the cards.

Under 'scroll', the document and the review column scroll together. The page does not change size when the pane opens or closes, so the document does not lay out again.

A fixed zoom has no room to give, so 'shrinkPage' acts as 'float' with a fixed zoom. A fit zoom with no maximum always shows the full column, because the page fills the width that remains. For more information, see Make room for the review pane.

overflow applies to the review pane only. The navigation pane stays docked at its side in every mode. It doesn't scroll sideways with the document, and it takes its room beside the page in the same way as when no review pane is mounted.

const modules = [reviewModule({ pane: { overflow: 'scroll' } })];

Combine display settings

Each setting controls one part of the display, so you can combine them freely. The following examples show common combinations.

Review tracked changes beside the text

Use this combination when reviewers decide on changes at the text and discuss in comment threads. The pane lists comments with initials badges, and each tracked change opens in a tracked change balloon at its text.

const modules = [reviewModule({ pane: { revisionsIn: 'balloons', commentMarkers: 'initials' } })];

Keep the sidebar closed by default

Use this combination when the document needs the most room. The page shows initials badges for comments and tracked change balloons for tracked changes. The pane opens only when the user asks for it.

const modules = [
  reviewModule({
    pane: { opening: 'manual', revisionsIn: 'balloons', commentMarkers: 'initials' },
  }),
];

Keep a fixed page size

Use this combination when the page must keep one size, such as for a layout that lines up with other content. The review column stands beside the page, and the user scrolls sideways to it.

const modules = [reviewModule({ pane: { overflow: 'scroll' } })];

Use comment icons instead of initials

Use this combination when author names are not meaningful to the reader. Comment markers show the comment icon, and tracked changes stay in the pane.

const modules = [reviewModule({ pane: { commentMarkers: 'icon' } })];

Change the display at runtime

Call setReviewPaneOptions on the editor instance with only the settings that change. The other settings keep their values. This method sets how the pane behaves. To open or close the pane itself, use setPaneOpen from useReview(). Read the settings in force from snapshot().reviewPane. DocxEditorReview reads the same settings, so the pane, the markers, and the tracked change balloons update at once.

setReviewPaneOptions returns an ExecResult:

  • { ok: true, changed: false } when the editor accepts the settings. changed answers for the document, and a display setting never changes the document. To detect a settings change, compare the snapshot().reviewPane reference, which changes only when a setting changes.
  • { ok: false, code: 'invalidArgs' } for an unknown setting or value.
  • { ok: false, code: 'unsupported' } when no review module is registered.

A refused call changes nothing.

The following component lets the user choose where tracked changes open and how comment markers look. Mount it anywhere under DocxEditor.Root. Each button prevents the default action of a mouse press, so the caret stays in the document.

import type { MouseEvent } from 'react';
import { useDocxEditor, useEditorState } from '@docx-editor.dev/react';
import type { CommentMarkerStyle, RevisionDisplay } from '@docx-editor.dev/pro/react';

const keepCaret = (event: MouseEvent) => event.preventDefault();

export function ReviewDisplaySwitch() {
  const editor = useDocxEditor();
  const revisionsIn = useEditorState((snapshot) => snapshot.reviewPane.revisionsIn);
  const markers = useEditorState((snapshot) => snapshot.reviewPane.commentMarkers);

  function showChangesIn(next: RevisionDisplay) {
    const result = editor?.setReviewPaneOptions({ revisionsIn: next });
    if (result && !result.ok) console.warn('Review display refused:', result.code);
  }

  function showMarkers(next: CommentMarkerStyle) {
    editor?.setReviewPaneOptions({ commentMarkers: next });
  }

  return (
    <div role="group" aria-label="Review display">
      <button
        type="button"
        aria-pressed={revisionsIn === 'pane'}
        onMouseDown={keepCaret}
        onClick={() => showChangesIn('pane')}
      >
        Changes in pane
      </button>
      <button
        type="button"
        aria-pressed={revisionsIn === 'balloons'}
        onMouseDown={keepCaret}
        onClick={() => showChangesIn('balloons')}
      >
        Tracked change balloons
      </button>
      <button
        type="button"
        aria-pressed={markers === 'initials'}
        onMouseDown={keepCaret}
        onClick={() => showMarkers(markers === 'initials' ? 'icon' : 'initials')}
      >
        Initials on markers
      </button>
    </div>
  );
}

useEditorState re-renders the component only when the selected value changes, so a bold toggle or a caret move does not re-render the switch.

Open the pane from your application

opening decides when the pane opens by itself. To open or close it from your own control, use useReview():

import { useReview } from '@docx-editor.dev/pro/react';

export function ReviewPaneButton() {
  const { paneOpen, setPaneOpen } = useReview();
  return (
    <button
      type="button"
      aria-pressed={paneOpen}
      onMouseDown={(event) => event.preventDefault()}
      onClick={() => setPaneOpen(!paneOpen)}
    >
      Review pane
    </button>
  );
}

Set tracked change colors and marks

Use revisionMarkup for marks, named colors, background highlights, changed-line bars, and cell shading. Use AuthorStyle for exact author colors and avatars.

Use ColorByChangeType to color unmatched insertions and deletions by kind. Author declarations take precedence for matched authors.

Viewer preferences stay outside the DOCX file and collaboration state. trackFormatting also controls future formatting revisions. Keep text marks so color is not the only indicator.

Markup option reference

Use these viewer preferences to customize tracked changes in your DOCX editor. Each text category accepts mark, color, and background.

CategoryDefault markDefault colorDefault background
insertionsunderlinebyAuthornone
deletionsstrikethroughbyAuthornone
movedFromdoubleStrikethroughgreennone
movedTodoubleUnderlinegreennone
formattingnonebyAuthornone

Text marks accept none, colorOnly, bold, italic, underline, doubleUnderline, strikethrough, and doubleStrikethrough. Deletion and moved-from marks also accept hidden, caret, and pound. A hidden deletion remains a pending change. It still needs an accessible review action.

Colors accept auto, byAuthor, or a named markup color. Backgrounds accept none, byAuthor, or a named markup color. Named colors are black, blue, turquoise, green, pink, red, yellow, white, darkBlue, teal, darkGreen, violet, darkRed, darkYellow, gray50, gray25, lightBlue, lightYellow, lightOrange, lightPurple, lightGreen, and gray.

OptionValues and defaults
changedLines.marknone, leftBorder, rightBorder, or outsideBorder. Default: outsideBorder.
changedLines.colorauto, byAuthor, or a named color. Default: auto.
cells.insertednone, byAuthor, or a named color. Default: lightBlue.
cells.deletedSame color choices. Default: pink.
cells.mergedSame color choices. Default: lightYellow.
cells.splitSame color choices. Default: lightOrange.
trackMovesBoolean. Default: true; false uses deletion and insertion presentation for move marks.
trackFormattingBoolean. Default: true; also controls the creation of future formatting revisions.

The markup setting does not make an unsupported tracked operation available. Keep the engine's refusal behavior for unsupported edits.

Controlled and runtime settings

Pass revisionMarkup to Root when application state controls the complete viewer configuration. Save proposed changes from onRevisionMarkupChange back into that prop. The callback also receives changes proposed by the markup dialog or API. Without that prop, the editor keeps its own preferences.

setRevisionMarkup merges omitted fields with the existing preferences. Controlled prop reconciliation resolves omitted values from the defaults. Choose one configuration owner to prevent application renders from reversing user changes.

Review display modes include simple-markup, all-markup, no-markup, and original. A display mode changes the view. It does not accept or reject a tracked change. Use the review controls to make document decisions.

Author declaration reference

DocxEditor.AuthorStyle matches an exact author name in the document. Its changes apply while the declaration is mounted. Removing the declaration restores the remaining author presentation.

PropUse
authorExact author name to match.
colorDocument mark color and review interface accent.
backgroundResting tracked text background.
activeBackgroundActive tracked change range background.
spanClassNameApplication class on painted tracked text spans.
avatarUrlApplication-approved author avatar URL.

Revision CSS property reference

PropertiesUse
--doc-revision-insertion, --doc-revision-deletionChange-type colors under ColorByChangeType.
--doc-revision-insertion-wash, --doc-revision-deletion-washResting tracked text backgrounds.
--doc-revision-insertion-active-bg, --doc-revision-insertion-active-borderActive insertion highlight.
--doc-revision-deletion-active-bg, --doc-revision-deletion-active-borderActive deletion highlight.
--doc-revision-other-active-bgActive formatting or structural change highlight.
--doc-revision-format, --doc-revision-format-washFormatting change presentation.
--doc-revision-insertion-decoration-styleUnderline style, such as solid or dotted.
--doc-revision-insertion-decoration-thicknessUnderline thickness. Default: 0.06em.
--doc-revision-insertion-underline-offsetUnderline distance. Default: 0.15em.
--doc-review-change-bar, --doc-review-change-bar-widthStandard change bar color and width. Default width: 1px.
--doc-review-change-bar-simple, --doc-review-change-bar-simple-widthSimple markup bar color and width. Default width: 2px.
--doc-review-author-0 through --doc-review-author-7Shared author palette.
--doc-revision-color-<name>On-screen value for a named markup color.

CSS properties affect the on-screen editor. They do not replace the exporter's named print colors.

React and Vue markup reference

Pass your DOCX bytes and loaded fonts to this component. The preview uses a fictional contract.

import { DocxEditor, type DocxEditorRootProps } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
import '@docx-editor.dev/core/styles/editor.css';

const modules = [reviewModule()];

export function ContractEditor({
  bytes,
  fonts,
}: {
  bytes: Uint8Array;
  fonts: DocxEditorRootProps['fonts'];
}) {
  return (
    <div className="docx-editor contract-workspace">
      <DocxEditor.Root
        document={bytes}
        fonts={fonts}
        modules={modules}
        author="Demo reviewer"
        mode="suggesting"
        reviewDisplayMode="all-markup"
        revisionMarkup={{
          insertions: { mark: 'underline', color: 'darkGreen', background: 'lightGreen' },
          deletions: { mark: 'strikethrough', color: 'darkRed', background: 'pink' },
        }}
      >
        <DocxEditor.ColorByChangeType />
        <DocxEditor.Viewport style={{ height: 520 }}>
          <DocxEditor.Content />
          <DocxEditor.HyperLink />
          <DocxEditor.ContextMenu />
          <DocxEditorReview />
        </DocxEditor.Viewport>
      </DocxEditor.Root>
    </div>
  );
}

Set author colors and avatars

The author name must match the document value. Author styling does not prove identity. Use a trusted application URL for each avatar.

<DocxEditor.AuthorStyle
  author="Legal reviewer"
  color="var(--doc-review-author-0)"
  background="transparent"
  avatarUrl="/avatars/legal-reviewer.png"
/>

Mount each declaration under the editor root. Override the author color palette with your application color tokens. Use --doc-revision-insertion-decoration-style to change insertion underlines. Use revisionMarkup.changedLines for change bar placement.

Named markup colors use --doc-revision-color-* tokens. revisionMarkup does not accept arbitrary CSS color strings. PDF output uses named print colors and the default author color palette. Application CSS and revisionStyles do not change PDF colors.

Style tracked change balloons

The review module can open each tracked change in a tracked change balloon at its text. Start with the built-in tracked change balloon. Build your own tracked change balloon only when your design needs a different component.

Use the built-in tracked change balloons

Set revisionsIn to 'balloons'. The review pane then lists comments only, and DocxEditorReview opens a tracked change balloon at the text of each tracked change. The tracked change balloon has the author, the date, the changed words, replies, a reply field, and Accept and Reject.

import { DocxEditor } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';

const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];

<DocxEditor.Root document={bytes} modules={modules} author="Jess Lin">
  <DocxEditor.Viewport style={{ height: 520 }}>
    <DocxEditor.Content />
    <DocxEditorReview />
  </DocxEditor.Viewport>
</DocxEditor.Root>;

To switch between the pane and tracked change balloons at runtime, and to combine tracked change balloons with other display settings, see Tracked changes.

To add a class to the built-in tracked change balloon, pass className to DocxEditorReview.Balloon. The part takes ReviewBalloonProps: className, and hidden to remove the built-in tracked change balloon.

<DocxEditorReview>
  <DocxEditorReview.Balloon className="contract-change-balloon" />
</DocxEditorReview>

Style the built-in tracked change balloon

Balloon.className styles the tracked change balloon root. Target its card to change the visible box. The following styles work with both framework examples.

.docx-editor.contract-workspace .contract-change-balloon .docx-review__card {
  border-radius: 12px;
  border-inline-start: 3px solid var(--doc-review-author-current);
  background: var(--doc-surface);
  box-shadow: 0 8px 24px var(--doc-border);
}
.docx-editor.contract-workspace .contract-change-balloon .docx-review__reply-box {
  border-radius: 8px;
}

Use border-radius: 3px for a square box. Keep the built-in actions and reply field when you change its appearance. The tracked change balloon pairs replacement records into one decision. For author colors and text marks, see Tracked changes.

Build a custom tracked change balloon

The live example styles the built-in tracked change balloon. The following reference shows how to replace it with your own component.

Keep revisionsIn: 'balloons' and pass hidden to DocxEditorReview.Balloon. The review pane then lists comments only, the built-in tracked change balloon stays closed, and your tracked change balloon is the route to each tracked change. This also removes built-in tracked change balloons for formatting and structural changes. Your component must handle every revision kind.

Read the item and its position from the editor, not from the painted pages:

  • On a click, call editor.getReviewItemsAt(event.clientX, event.clientY, { pairReplacements: true }). It returns the review items under the point, innermost first. Open the first revision. Do not call setActive on a click, because it moves the caret to the start of the change.
  • To place the tracked change balloon, call editor.getReviewItemRects(key). It returns the painted bands of the item in client coordinates. Anchor the tracked change balloon to the first band. If the array is empty, the item has no painted text, so hide the tracked change balloon. Read the bands again after a scroll, a resize, each change event, a zoom change, and when the review pane opens or closes.
  • To follow Next Change and Previous Change, listen for reviewItemReveal with useEditorEvent. Navigation steps through the unpaired queue, so for a replacement the event's key names one half. The event also carries pairKey, the key of the pair, so open pairKey ?? key. If your query excludes replacements, with excludeRevisionKinds: ['replace'], open key, because pairKey is not in that list. You can read activatedKey from useReview() instead. Do not open the tracked change balloon for activeKey. It follows the caret, so the tracked change balloon opens each time the caret lands in a change.
  • Pass the same query, { pairReplacements: true }, to useReview() and to the hit test, so that a hit names an item in your list. A replacement is then one item with revisionKind: 'replace'.
  • Check the Boolean that accept and reject return. Handle Escape, dismissal, read-only items, and cleanup.

React and Vue custom component reference

Pass your DOCX bytes and loaded fonts to this component. The preview uses a fictional contract.

import { useRef } from 'react';
import { ContractBalloons } from './ContractBalloons';
import { DocxEditor, type DocxEditorRootProps } from '@docx-editor.dev/react';
import { reviewModule, DocxEditorReview } from '@docx-editor.dev/pro/react';
import '@docx-editor.dev/core/styles/editor.css';

const modules = [reviewModule({ pane: { revisionsIn: 'balloons' } })];

export function ContractEditor({
  bytes,
  fonts,
}: {
  bytes: Uint8Array;
  fonts: DocxEditorRootProps['fonts'];
}) {
  const container = useRef<HTMLDivElement>(null);
  return (
    <div className="docx-editor contract-workspace">
      <DocxEditor.Root
        document={bytes}
        fonts={fonts}
        modules={modules}
        author="Demo reviewer"
        mode="suggesting"
        reviewDisplayMode="all-markup"
      >
        <DocxEditor.Toolbar />
        <div ref={container} className="contract-stage">
          <DocxEditor.Viewport style={{ height: 520 }}>
            <DocxEditor.Content />
            <DocxEditor.HyperLink />
            <DocxEditor.ContextMenu />
            <DocxEditorReview>
              <DocxEditorReview.Balloon hidden />
            </DocxEditorReview>
          </DocxEditor.Viewport>
          <ContractBalloons container={container} rounded />
        </div>
      </DocxEditor.Root>
    </div>
  );
}

Create the custom component

Create ContractBalloons.tsx or ContractBalloons.vue with these examples. Both use the same placement helper. Set authorAccent in React or author-accent in Vue to tint the tracked change balloon by author.

'use client';

import { useEffect, useRef, useState, type RefObject } from 'react';
import { useDocxEditor, useEditorEvent, useReviewAuthors } from '@docx-editor.dev/react';
import { useReview } from '@docx-editor.dev/pro/react';
import { revisionLabel, watchBalloonAnchor, type BalloonPosition } from './watch-balloon-anchor';

/** One key for a replacement, in the hit test and in the review queue. */
const query = { pairReplacements: true };

/** Custom tracked change balloons. */
export function ContractBalloons({
  container,
  rounded,
  authorAccent = false,
}: {
  container: RefObject<HTMLDivElement | null>;
  rounded: boolean;
  authorAccent?: boolean;
}) {
  const editor = useDocxEditor();
  const { items, accept, reject } = useReview(query);
  const authors = useReviewAuthors();
  const panel = useRef<HTMLDivElement>(null);
  const [openKey, setOpenKey] = useState<string | null>(null);
  const [position, setPosition] = useState<BalloonPosition | null>(null);
  const [failure, setFailure] = useState('');
  const item = items.find((entry) => entry.key === openKey && entry.kind === 'revision');
  const itemKey = item?.key;

  // Next Change and Previous Change ask the host to open an item.
  // Navigation names one half of a replacement; pairKey names the pair in `items`.
  useEditorEvent('reviewItemReveal', ({ key, pairKey }) => {
    setFailure('');
    setOpenKey(pairKey ?? key);
  });

  useEffect(() => {
    const host = container.current;
    if (!host || !editor) return;
    const click = (event: MouseEvent) => {
      if (event.defaultPrevented || !host.ownerDocument.getSelection()?.isCollapsed) return;
      if (event.target instanceof Node && panel.current?.contains(event.target)) return;
      // Read the item under the pointer. Do not activate it: that moves the caret.
      const hit = editor
        .getReviewItemsAt(event.clientX, event.clientY, query)
        .find((entry) => entry.placement.kind === 'revision');
      setFailure('');
      setOpenKey(hit?.placement.key ?? null);
    };
    const keydown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') setOpenKey(null);
    };
    host.addEventListener('click', click);
    host.addEventListener('keydown', keydown);
    return () => {
      host.removeEventListener('click', click);
      host.removeEventListener('keydown', keydown);
    };
  }, [container, editor]);

  useEffect(() => {
    const host = container.current;
    if (!host || !editor || !itemKey) {
      setPosition(null);
      return;
    }
    return watchBalloonAnchor(editor, host, itemKey, setPosition);
  }, [container, editor, itemKey]);

  if (!item || item.kind !== 'revision' || !position) return null;
  const authorColor =
    authors.find((author) => author.author === item.author)?.color ?? 'var(--doc-primary)';
  return (
    <div
      ref={panel}
      className="contract-balloon"
      style={{
        ...position,
        borderRadius: rounded ? 16 : 4,
        borderInlineStart: authorAccent ? `3px solid ${authorColor}` : undefined,
        background: authorAccent
          ? `color-mix(in srgb, ${authorColor} 8%, var(--doc-surface))`
          : undefined,
      }}
      data-review-author={item.author}
      role="group"
      aria-label="Tracked change balloon"
      onMouseDown={(event) => event.preventDefault()}
    >
      <div className="contract-balloonHeading">
        <span className="contract-initials">{item.initials}</span>
        <strong>{item.author}</strong>
        <button
          type="button"
          aria-label="Close tracked change balloon"
          onClick={() => setOpenKey(null)}
        >
          ×
        </button>
      </div>
      <p>{revisionLabel(item.revisionKind)}</p>
      {item.replacedText && (
        <p className="contract-excerpt">
          <del>{item.replacedText}</del>
        </p>
      )}
      <p className="contract-excerpt">{item.text}</p>
      <div className="contract-decisions">
        <button
          type="button"
          disabled={item.readOnly}
          onClick={() => {
            if (!accept(item))
              setFailure('The editor refused this change. Review the document and try again.');
          }}
        >
          Accept
        </button>
        <button
          type="button"
          disabled={item.readOnly}
          onClick={() => {
            if (!reject(item))
              setFailure('The editor refused this change. Review the document and try again.');
          }}
        >
          Reject
        </button>
      </div>
      {failure && <p role="alert">{failure}</p>}
    </div>
  );
}

Place the tracked change balloon

Create watch-balloon-anchor.ts beside your tracked change balloon component. It reads getReviewItemRects(key) and positions the component below the first text band.

The helper updates after scrolling, resizing, document changes, zoom changes, and review pane transitions. It hides the component when the item has no visible band.

import type { Editor } from '@docx-editor.dev/core';

export interface BalloonPosition {
  left: number;
  top: number;
}

/** Places a balloon under the first painted band of one review item. */
export function watchBalloonAnchor(
  editor: Editor,
  host: HTMLElement,
  key: string,
  notify: (position: BalloonPosition | null) => void
): () => void {
  let frame = 0;
  function update() {
    frame = 0;
    const [band] = editor.getReviewItemRects(key);
    const bounds = host.getBoundingClientRect();
    if (!band || band.bottom < bounds.top || band.top > bounds.bottom) {
      notify(null);
      return;
    }
    const width = Math.min(280, host.clientWidth - 24);
    notify({
      left: Math.max(12, Math.min(band.left - bounds.left, host.clientWidth - width - 12)),
      top: band.bottom - bounds.top + 8,
    });
  }
  function schedule() {
    if (!frame) frame = requestAnimationFrame(update);
  }
  update();
  const resize = new ResizeObserver(schedule);
  resize.observe(host);
  host.addEventListener('scroll', schedule, true);
  // The pages slide when the review pane opens or closes; read again when they stop.
  host.addEventListener('transitionend', schedule);
  const offChange = editor.on('change', schedule);
  // Zoom and the review pane move the pages without a document change.
  let { zoom, reviewPaneOpen } = editor.snapshot();
  const offView = editor.on('selectionChange', (snapshot) => {
    if (snapshot.zoom === zoom && snapshot.reviewPaneOpen === reviewPaneOpen) return;
    ({ zoom, reviewPaneOpen } = snapshot);
    schedule();
  });
  return () => {
    cancelAnimationFrame(frame);
    resize.disconnect();
    host.removeEventListener('scroll', schedule, true);
    host.removeEventListener('transitionend', schedule);
    offChange();
    offView();
  };
}

/** A short heading for each kind of tracked change. */
export function revisionLabel(kind: string): string {
  if (kind === 'insert') return 'Inserted text';
  if (kind === 'delete') return 'Deleted text';
  if (kind === 'replace') return 'Replaced text';
  return 'Tracked change';
}

Style the tracked change balloon

Load these application rules after the editor stylesheet. Both framework examples use the same container and tracked change balloon classes.

.contract-stage {
  position: relative;
  min-height: 0;
}
.docx-editor .contract-balloon {
  position: absolute;
  z-index: 40;
  width: min(280px, calc(100% - 24px));
  padding: 14px;
  border: 1px solid var(--doc-border);
  border-radius: 16px;
  background: var(--doc-surface);
  color: var(--doc-text);
  box-shadow: 0 12px 32px var(--doc-border);
}
.docx-editor .contract-balloon button {
  margin: 4px;
  padding: 5px 8px;
  border: 1px solid var(--doc-border);
  border-radius: 6px;
  background: var(--doc-bg);
  color: var(--doc-text);
}
.docx-editor .contract-balloon button:focus-visible {
  outline: 2px solid var(--doc-primary);
  outline-offset: 3px;
}

A replacement records a deletion and an insertion. With useReview({ pairReplacements: true }), the tracked change balloon shows them as one item, and Accept and Reject decide both. For more information, see Show a replacement as one item.

API references