Custom UI

Build custom search controls

Drive the Editor's visual search session from application-owned controls.

Use ui.search when your application owns the find interface but still needs SuperDoc to highlight, reveal, navigate, and replace matches in the open Editor.

This is an Editor search session. For programmatic content discovery and durable mutation targets, use Document API queries.

Add the controls

Create a query field, navigation controls, replacement actions, status output, and Editor container:

<form id="search-controls" role="search">
  <input id="search-query" type="search" placeholder="Find in document" aria-label="Find in document" />
  <label><input id="match-case" type="checkbox" /> Match case</label>
  <button id="previous-match" type="button" disabled>Previous</button>
  <button id="next-match" type="button" disabled>Next</button>
  <output id="search-count" for="search-query">No matches</output>

  <input id="replacement" type="text" placeholder="Replacement" aria-label="Replacement text" />
  <button id="replace-match" type="button" disabled>Replace</button>
  <button id="replace-all" type="button" disabled>Replace all</button>
</form>

<p id="search-status" role="status"></p>
<div id="editor" style="height: 70vh"></div>

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

Copy the tracked-changes fixture to your app's public directory as contract.docx, or use another DOCX.

Bind the search handle

Read the Editor's controller, superdoc.ui, after readiness, then observe ui.search as the source of match and availability state.

import { SuperDoc } from 'superdoc';
import type { WorkflowActionResult } from 'superdoc/ui';
import 'superdoc/style.css';

const query = document.querySelector<HTMLInputElement>('#search-query');
const matchCase = document.querySelector<HTMLInputElement>('#match-case');
const previous = document.querySelector<HTMLButtonElement>('#previous-match');
const next = document.querySelector<HTMLButtonElement>('#next-match');
const count = document.querySelector<HTMLOutputElement>('#search-count');
const replacement = document.querySelector<HTMLInputElement>('#replacement');
const replace = document.querySelector<HTMLButtonElement>('#replace-match');
const replaceAll = document.querySelector<HTMLButtonElement>('#replace-all');
const status = document.querySelector<HTMLParagraphElement>('#search-status');

if (!query || !matchCase || !previous || !next || !count || !replacement || !replace || !replaceAll || !status) {
  throw new Error('The search UI is incomplete.');
}

let stopSearch: (() => void) | null = null;
let removeHandlers: (() => void) | null = null;

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/contract.docx',
  onReady: ({ superdoc: readySuperDoc }) => {
    const ui = readySuperDoc.ui;

    const render = (search: ReturnType<typeof ui.search.getSnapshot>) => {
      const hasMatches = search.total > 0;
      previous.disabled = !hasMatches;
      next.disabled = !hasMatches;
      replace.disabled = !search.canReplace;
      replaceAll.disabled = !search.canReplace;
      count.textContent = hasMatches ? `${search.activeIndex + 1} of ${search.total}` : 'No matches';
      status.textContent = search.reason ?? '';
    };

    const runSearch = () => {
      if (!query.value) {
        ui.search.clear();
        return;
      }

      const opened = ui.search.open();
      if (!opened.ok) {
        status.textContent = opened.reason ?? 'Search is unavailable.';
        return;
      }

      render(ui.search.search(query.value, { caseSensitive: matchCase.checked }));
    };

    const reportAction = (result: WorkflowActionResult) => {
      if (!result.ok) status.textContent = result.reason ?? 'The search action did not run.';
    };

    const replaceCurrent = async () => {
      reportAction(await ui.search.replace(replacement.value));
    };

    const replaceEveryMatch = async () => {
      reportAction(await ui.search.replaceAll(replacement.value));
    };

    const goPrevious = () => reportAction(ui.search.previous());
    const goNext = () => reportAction(ui.search.next());

    render(ui.search.getSnapshot());
    stopSearch = ui.search.observe(render);
    query.addEventListener('input', runSearch);
    matchCase.addEventListener('change', runSearch);
    previous.addEventListener('click', goPrevious);
    next.addEventListener('click', goNext);
    replace.addEventListener('click', replaceCurrent);
    replaceAll.addEventListener('click', replaceEveryMatch);

    removeHandlers = () => {
      query.removeEventListener('input', runSearch);
      matchCase.removeEventListener('change', runSearch);
      previous.removeEventListener('click', goPrevious);
      next.removeEventListener('click', goNext);
      replace.removeEventListener('click', replaceCurrent);
      replaceAll.removeEventListener('click', replaceEveryMatch);
    };
  },
});

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

The snapshot provides the state needed by a focused search interface:

  • total and activeIndex describe the current match.
  • available and reason explain whether the host can search.
  • canReplace reflects document mode, host support, and whether the complete match set can be changed safely.
  • query, caseSensitive, and regex describe the active session.

search() returns the best-known snapshot immediately. A worker-backed search can settle later, so keep the interface subscribed with observe() instead of treating the first return value as final.

next() and previous() update the active match and reveal it in the document. Check their ok result before announcing navigation.

Await replacements

replace() and replaceAll() can settle asynchronously. Await them before saving the document or starting dependent work.

A failed result includes a stable reason such as an unavailable search host, a read-only document, or an operation the current session cannot perform. Keep replace controls synchronized with canReplace and still inspect the returned result.

Call clear() when the query becomes empty. Call close() when the application dismisses the search experience and should remove its highlights.

For the standard interface, use Find and replace in the built-in UI.

On this page