Build custom find and replace controls
Render Search in your application while SuperDoc finds, highlights, and replaces document text.
Use built-in Search 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
The example starts with eight case-insensitive matches for Client across three pages.
- Choose Next and watch the active match move through the document.
- Turn on Match case. The count changes from eight to seven.
- Replace the active match with
Customer. The document and count update together.
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
Start from Build your first custom control. Download the sample to
your app's public directory as search-sample.docx:
Vanilla adds the controls beside its existing Editor container. Replace index.html with:
<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:
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();
});
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
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
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:
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
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 when code needs document targets instead of a visual Search session. Build an application-owned context menu when actions depend on the entity or selection under the pointer. Otherwise, return to the Custom UI overview.