An original maintenance note uses the phrase “filter housing” in its inspection steps and in a linked replacement instruction. A local find control should highlight both occurrences while keeping that link usable. Assigning a modified string to the article's innerHTML would rebuild its descendants and can discard their event listeners.
Give the search a precise scope
Keep the controls outside the content they search:
<label for="article-find">Find in this article (case sensitive)</label>
<input id="article-find" type="search">
<p id="find-count" role="status"></p>
<article id="maintenance-note">
<h1>Filter replacement</h1>
<p>Inspect the filter housing before opening it.</p>
<p>Read the <a href="/manual/filter-housing">filter housing</a>
replacement instruction.</p>
</article>
Replace the example manual route with the application's actual destination. The control searches this article, not a server index or every document on the site. It matches exact, case-sensitive text within each text node. That explicit limitation keeps this small implementation predictable.
Paint literal matches with ranges
::highlight(article-find-match) {
background-color: #ffe28a;
color: #202020;
}
Run this script after the markup exists:
const article = document.getElementById('maintenance-note');
const query = document.getElementById('article-find');
const count = document.getElementById('find-count');
if (!globalThis.CSS?.highlights || !globalThis.Highlight) {
query.disabled = true;
count.textContent = 'Use your browser Find command for this article.';
} else {
query.addEventListener('input', () => {
CSS.highlights.delete('article-find-match');
const term = query.value;
if (!term) {
count.textContent = '';
return;
}
const ranges = [];
const walker = document.createTreeWalker(
article, NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return node.parentElement.closest(
'script, style, [hidden], [aria-hidden="true"]'
) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
}
);
while (walker.nextNode()) {
const node = walker.currentNode;
let from = 0;
let at;
while ((at = node.data.indexOf(term, from)) !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + term.length);
ranges.push(range);
from = at + term.length;
}
}
CSS.highlights.set('article-find-match', new Highlight(...ranges));
count.textContent = `${ranges.length} matches in this article`;
});
}
The query is a literal string, so punctuation such as [ does not become a regular expression. Advancing by the term's length counts non-overlapping matches. An empty query exits before searching, which avoids repeatedly finding an empty string.
The TreeWalker API visits the article's text nodes. Each Range uses offsets within its own text node. Painting those ranges does not add a mark element, change the stored article, or replace a link.
Keep the small implementation's limits visible
A phrase split across markup, such as filter <strong>housing</strong>, spans separate text nodes and will not match as a whole here. A production finder that supports that case needs a combined text index mapped back to node offsets. Do not claim this per-node example searches uninterrupted rendered sentences across every tag boundary.
The filter skips explicit hidden and aria-hidden="true" ancestors plus script and style elements. It does not calculate every CSS visibility rule, search closed shadow roots, or search another iframe. Keep the example scoped to a static visible article. If the application changes the article after a query, rerun the search against the current content rather than leaving an outdated count.
The HTML search landmark guide covers naming a search region. This implementation supplies the local matching behavior. The String.search guide covers a different operation: returning one regular-expression match index from a string.
Try filter housing and expect two highlights in the original fixture. Search Filter housing and expect zero under the stated case-sensitive rule. Clear the field and expect the highlights and count text to disappear. Finally, activate the replacement link and confirm it still follows the real manual destination. Keep the browser's Find command available when a reader needs capabilities beyond this local control.