# 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](/document-api/query-content).

## Add the controls [#add-the-controls]

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

```html
<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](/fixtures/tracked-changes.docx) to your app's public directory as `contract.docx`, or use another DOCX.

## Bind the search handle [#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.

```ts
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.

## Observe after starting a search [#observe-after-starting-a-search]

`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 [#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](/editor/built-in-ui/search-and-replace).
