# Build custom find and replace controls

> Render Search in your application while SuperDoc finds, highlights, and replaces document text.



Use [built-in Search](/editor/built-in-ui/search-and-replace) when its standard controls fit. Build custom controls when
Search belongs in your application shell or needs different interaction design.

This guide replaces only the Search surface. SuperDoc continues to find and highlight matches, move the active match
into view, and apply replacements.

## Try application-owned Search [#try-application-owned-search]

The example starts with eight case-insensitive matches for `Client` across three pages.

1. Choose **Next** and watch the active match move through the document.
2. Turn on **Match case**. The count changes from eight to seven.
3. Replace the active match with `Customer`. The document and count update together.

> **Live example: drive Search from application-owned controls**
>
> SuperDoc renders its toolbar and a three-page DOCX while the application renders Find, Match case, Previous, Next, and Replace controls. The example starts with eight case-insensitive `Client` matches. `ui.search` paints and navigates the matches, while the panel observes the same session for its active index, total, and `canReplace` state. The document starts at 80% on wide layouts and fits to width on narrow layouts.


The controls call `ui.search` instead of reading the rendered document. The same Search session paints the highlights,
tracks the active match, and reports whether replacement is currently available.

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

Start from [Build your first custom control](/editor/custom-ui/controller-setup). Download the sample to
your app's `public` directory as `search-sample.docx`:

[Download the Search sample](/fixtures/search-sample.docx): Three pages and one pending deletion · DOCX


Vanilla adds the controls beside its existing Editor container. Replace `index.html` with:

```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>
  <label><input id="include-deletions" type="checkbox" /> Include pending deletions</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>

```

Replace the setup guide's Editor code:

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

```ts
import { SuperDoc } from 'superdoc';
import type { UIConfig } from 'superdoc';
import type { SearchSnapshot, WorkflowActionResult } 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 search control: ${selector}`);
  return element;
}

const searchForm = getElement<HTMLFormElement>('#search-controls');
const query = getElement<HTMLInputElement>('#search-query');
const matchCase = getElement<HTMLInputElement>('#match-case');
const includeDeletions = getElement<HTMLInputElement>('#include-deletions');
const previous = getElement<HTMLButtonElement>('#previous-match');
const next = getElement<HTMLButtonElement>('#next-match');
const count = getElement<HTMLOutputElement>('#search-count');
const replacement = getElement<HTMLInputElement>('#replacement');
const replace = getElement<HTMLButtonElement>('#replace-match');
const replaceAll = getElement<HTMLButtonElement>('#replace-all');
const status = getElement<HTMLParagraphElement>('#search-status');

const editorUi = { search: false } satisfies UIConfig;
let stopBindings: (() => void) | null = null;
let replacementPending = false;
let actionStatus = '';

const superdoc = new SuperDoc({
  selector: '#editor',
  document: '/search-sample.docx',
  ui: editorUi,
  onReady: ({ superdoc: readySuperDoc }) => {
    stopBindings?.();
    replacementPending = false;
    actionStatus = '';
    const { search } = readySuperDoc.ui;

    const render = (snapshot: SearchSnapshot) => {
      const hasMatches = snapshot.total > 0;
      query.disabled = replacementPending;
      matchCase.disabled = replacementPending;
      includeDeletions.disabled = replacementPending;
      previous.disabled = !hasMatches || replacementPending;
      next.disabled = !hasMatches || replacementPending;
      replacement.disabled = replacementPending;
      // `canReplace` is document mutability; a replacement still needs a match.
      replace.disabled = !hasMatches || !snapshot.canReplace || replacementPending;
      // A truncated match set can replace the active match but not all of them.
      replaceAll.disabled = !snapshot.canReplaceAll || replacementPending;
      count.textContent = hasMatches
        ? snapshot.activeIndex >= 0
          ? `${snapshot.activeIndex + 1} of ${snapshot.total}`
          : `${snapshot.total} matches`
        : 'No matches';
      status.textContent = snapshot.reason ?? actionStatus;
    };

    const runSearch = () => {
      actionStatus = '';
      if (!query.value) {
        search.clear();
        return;
      }
      search.find(query.value, {
        caseSensitive: matchCase.checked,
        includeTrackedDeletions: includeDeletions.checked,
      });
    };

    const report = (result: WorkflowActionResult) => {
      actionStatus = result.ok ? '' : (result.reason ?? 'The search action is unavailable.');
      status.textContent = actionStatus;
    };

    const runReplacement = async (action: () => WorkflowActionResult | Promise<WorkflowActionResult>) => {
      if (replacementPending) return;
      replacementPending = true;
      render(search.getSnapshot());
      try {
        report(await action());
      } finally {
        replacementPending = false;
        render(search.getSnapshot());
      }
    };
    const replaceCurrent = () => runReplacement(() => search.replace(replacement.value));
    const replaceEveryMatch = () => runReplacement(() => search.replaceAll(replacement.value));
    const goPrevious = () => report(search.previous());
    const goNext = () => report(search.next());
    const preventSubmit = (event: SubmitEvent) => event.preventDefault();

    const stopSearch = search.observe(render);
    searchForm.addEventListener('submit', preventSubmit);
    query.addEventListener('input', runSearch);
    matchCase.addEventListener('change', runSearch);
    includeDeletions.addEventListener('change', runSearch);
    // Text typed before the document opened has no session yet. Run it now.
    runSearch();
    previous.addEventListener('click', goPrevious);
    next.addEventListener('click', goNext);
    replace.addEventListener('click', replaceCurrent);
    replaceAll.addEventListener('click', replaceEveryMatch);

    stopBindings = () => {
      stopSearch();
      search.close();
      searchForm.removeEventListener('submit', preventSubmit);
      query.removeEventListener('input', runSearch);
      matchCase.removeEventListener('change', runSearch);
      includeDeletions.removeEventListener('change', runSearch);
      previous.removeEventListener('click', goPrevious);
      next.removeEventListener('click', goNext);
      replace.removeEventListener('click', replaceCurrent);
      replaceAll.removeEventListener('click', replaceEveryMatch);
    };
  },
  onContentError: ({ error }) => {
    status.textContent = 'The document could not be opened.';
    console.error(error);
  },
  onException: ({ error }) => {
    status.textContent = 'The document could not be opened.';
    console.error(error);
  },
});

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

```

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

```tsx
import { useEffect, useState } from 'react';
import { SuperDocEditor } from '@superdoc/react';
import type { UIConfig } from 'superdoc';
import type { WorkflowActionResult } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocSearch, useSuperDocUI } from 'superdoc/ui/react';
import '@superdoc/react/style.css';

const editorUi = { search: false } satisfies UIConfig;

export default function App() {
  return (
    <SuperDocUIProvider>
      <main className='search-layout'>
        <SearchControls />
        <Editor />
      </main>
    </SuperDocUIProvider>
  );
}

function Editor() {
  const setSuperDoc = useSetSuperDoc();

  return (
    <SuperDocEditor
      document='/search-sample.docx'
      onContentError={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onException={({ error }) => console.error('SuperDoc could not open the document.', error)}
      onReady={({ superdoc }) => setSuperDoc(superdoc)}
      ui={editorUi}
    />
  );
}

function SearchControls() {
  const ui = useSuperDocUI();
  const search = useSuperDocSearch();
  const [query, setQuery] = useState('');
  const [replacement, setReplacement] = useState('');
  const [matchCase, setMatchCase] = useState(false);
  const [includeDeletions, setIncludeDeletions] = useState(false);
  const [replacementPending, setReplacementPending] = useState(false);
  const [status, setStatus] = useState('');

  // Runs on mount, whenever the query or its options change, and again once
  // the Editor becomes ready, so text typed while loading still searches.
  useEffect(() => {
    if (!ui) return;
    setStatus('');
    if (!query) {
      ui.search.clear();
      return;
    }
    ui.search.find(query, { caseSensitive: matchCase, includeTrackedDeletions: includeDeletions });
  }, [includeDeletions, matchCase, query, ui]);

  useEffect(() => () => ui?.search.close(), [ui]);

  function report(result: WorkflowActionResult) {
    setStatus(result.ok ? '' : (result.reason ?? 'The search action is unavailable.'));
  }

  function goPrevious() {
    if (ui && !replacementPending) report(ui.search.previous());
  }

  function goNext() {
    if (ui && !replacementPending) report(ui.search.next());
  }

  async function runReplacement(action: () => WorkflowActionResult | Promise<WorkflowActionResult>) {
    if (replacementPending) return;
    setReplacementPending(true);
    try {
      report(await action());
    } finally {
      setReplacementPending(false);
    }
  }

  function replaceCurrent() {
    if (ui) return runReplacement(() => ui.search.replace(replacement));
  }

  function replaceEveryMatch() {
    if (ui) return runReplacement(() => ui.search.replaceAll(replacement));
  }

  const hasMatches = search.total > 0;
  const matchCount =
    search.activeIndex >= 0 ? `${search.activeIndex + 1} of ${search.total}` : `${search.total} matches`;

  return (
    <form onSubmit={(event) => event.preventDefault()} role='search'>
      <input
        aria-label='Find in document'
        disabled={replacementPending}
        onChange={(event) => setQuery(event.target.value)}
        placeholder='Find in document'
        type='search'
        value={query}
      />
      <label>
        <input
          checked={matchCase}
          disabled={replacementPending}
          onChange={(event) => setMatchCase(event.target.checked)}
          type='checkbox'
        />
        Match case
      </label>
      <label>
        <input
          checked={includeDeletions}
          disabled={replacementPending}
          onChange={(event) => setIncludeDeletions(event.target.checked)}
          type='checkbox'
        />
        Include pending deletions
      </label>
      <button disabled={!hasMatches || replacementPending} onClick={goPrevious} type='button'>
        Previous
      </button>
      <button disabled={!hasMatches || replacementPending} onClick={goNext} type='button'>
        Next
      </button>
      <output>{hasMatches ? matchCount : 'No matches'}</output>

      <input
        aria-label='Replacement text'
        disabled={replacementPending}
        onChange={(event) => setReplacement(event.target.value)}
        placeholder='Replacement'
        type='text'
        value={replacement}
      />
      <button
        disabled={!hasMatches || !search.canReplace || replacementPending}
        onClick={() => void replaceCurrent()}
        type='button'
      >
        {replacementPending ? 'Replacing…' : 'Replace'}
      </button>
      <button
        disabled={!search.canReplaceAll || replacementPending}
        onClick={() => void replaceEveryMatch()}
        type='button'
      >
        Replace all
      </button>
      <p aria-live='polite' role='status'>
        {search.reason ?? status}
      </p>
    </form>
  );
}

```


`ui: { search: false }` hides SuperDoc's Search surface. It does not disable `superdoc.ui.search`.
This example keeps the controls visible. If your surface opens on demand, your application also owns its keyboard
shortcut and focus behavior.

## Keep controls synchronized [#keep-controls-synchronized]

`find()` starts a visual Search session. SuperDoc highlights the matches, and `next()` or `previous()` moves the active
match into view. `observe()` in Vanilla and `useSuperDocSearch()` in React keep the result count and replacement controls
synchronized as the session settles.

`canReplace` reflects the current document mode, not the match count, so it can be `true` before a query runs. Keep
**Replace** disabled unless there is a match, `canReplace` is `true`, and no replacement is pending. Gate **Replace
all** on `canReplaceAll` instead: it already requires a match, and a session with more matches than SuperDoc
enumerates can still replace the active match but refuses to replace every match. Check the action result because the
document can change after the controls render.

## Include pending deletions when needed [#include-pending-deletions-when-needed]

Pending tracked deletions are excluded by default. Both examples expose the option as an **Include pending deletions**
checkbox that passes `includeTrackedDeletions` to `find()`. For a one-off review search, call it directly:

```ts
import type { SuperDoc } from 'superdoc';

export function findPendingDeletion(superdoc: SuperDoc) {
  return superdoc.ui.search.find('Legacy', {
    includeTrackedDeletions: true,
  });
}

```

This finds the deleted text. It does not accept, reject, or restore the tracked change.

## Verify the controls [#verify-the-controls]

Run the project. Search for `Client`, then move through the eight matches across three pages. Replace one match and
confirm that the count updates. Turn on **Include pending deletions** and search for `Legacy`; the pending deletion
should be the only match.

Use [Document API queries](/document-api/query-content) when code needs document targets instead of a visual Search
session. Build an [application-owned context menu](/editor/custom-ui/context-menus) when actions depend on the entity or
selection under the pointer. Otherwise, return to the [Custom UI overview](/editor/custom-ui/overview).
