You're staring at a massive dashboard or a page packed with content, you hit Ctrl+F, type your keyword, and the tiny highlighted word just disappears into the noise. You know the match is somewhere on this page, but you can't actually find it.
So I built Container Keyword Finder — a Chrome extension that solves this by thinking at the container level instead of the word level.
The idea came from DevTools
The actual idea came from messing around in Chrome DevTools. I noticed that when you hover over an element in the inspector, it highlights that element on the page with a blue overlay. That's when it clicked — what if instead of highlighting the word, I highlight the whole container around it?
From there it was pretty straightforward conceptually: inject a CSS class onto the container element, add an outline, done. The trickier part was figuring out how to walk up the DOM tree 🤔
What it does
Instead of highlighting the matched word, it highlights the entire parent element containing that word — the <p>, <li>, <div>, <article>, or whatever block wraps the text. A coloured outline appears around the whole container, making it immediately obvious where on the page you are.
A 300px wide highlighted <div> is a lot harder to miss than a 40px highlighted word.
The ancestor chain navigation
Each result has ↑ ↓ buttons that let you move the highlight up or down the DOM tree independently per match.
Say the keyword is inside a <p> inside a <div class="card"> inside an <article>. At level 0 the extension highlights the <p>. Press ↑ and it highlights the <div>. Press ↑ again and it highlights the <article>.
<article> ← level 2
<div class="card"> ← level 1
<p>keyword is here</p> ← level 0 (default)
</div>
</article>
The highlight on the page updates instantly as you navigate. And each match is independent — moving match 1 up to <article> level doesn't affect match 2.
How it works under the hood
TreeWalker for text nodes
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode }
);
TreeWalker with SHOW_TEXT visits only raw text nodes — the actual string content inside elements, not the elements themselves. This lets us search the entire page's visible text in one efficient pass, skipping <script>, <style>, <iframe> subtrees entirely.
Building the ancestor chain
Once we find a text node containing the keyword, we walk up the DOM collecting every semantic ancestor:
function buildChain(el) {
const chain = [];
let current = el;
while (current && current !== document.body) {
if (SEMANTIC_BLOCKS.has(current.tagName)) {
chain.push(current);
}
current = current.parentElement;
}
return chain; // [<p>, <div>, <article>, <main>]
}
The chain is stored alongside a currentLevel pointer. Navigating up/down just increments or decrements currentLevel and re-applies the CSS classes.
Deduplication via Map
If the keyword appears three times inside the same <p>, we only want one highlight. We use a Map keyed by the DOM element reference, which deduplicates automatically:
const containerMap = new Map();
for (const node of textNodes) {
if (!regex.test(node.textContent)) continue;
const innermost = findInnermostContainer(node);
if (!innermost || containerMap.has(innermost)) continue;
containerMap.set(innermost, { ... });
}
Input field values
Ctrl+F misses input values because they're not text nodes — they live in .value. We handle this separately by querying all text-based inputs directly:
root.querySelectorAll('input, textarea').forEach(el => {
const value = el.value || '';
if (regex.test(value)) {
results.push({ chain: [el, ...buildChain(el)], isInput: true, ... });
}
});
The manifest.json — where it all starts
Every Chrome extension starts with a manifest.json, it's the config file Chrome reads to understand what your extension is and what it's allowed to do. Ours declares two things Chrome needs to know upfront: what permissions the extension needs, and when to inject content.js into pages.
{
"manifest_version": 3,
"permissions": ["activeTab", "scripting", "storage"],
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"action": {
"default_popup": "index.html"
}
}
The content_scripts block is what makes the extension feel seamless — Chrome automatically injects content.js into every page the user visits, so by the time they open the popup the search script is already sitting there ready to receive messages. run_at: document_idle means we wait until the DOM is fully loaded before injecting, so we never try to walk a half-rendered page.
The Chrome extension architecture
The extension runs in two completely isolated JavaScript environments that can't share variables directly — they can only talk through Chrome's message passing API:
| Popup (React) | Content script | |
|---|---|---|
| Runs in | Its own browser context | The visited webpage |
| Has access to | Chrome APIs, React state | Page DOM, document
|
| Talks via | chrome.tabs.sendMessage |
chrome.runtime.onMessage |
DOM element references can't travel between them — only serialisable data. That's why each match is tracked by index.
State persistence
Chrome closes the popup every time you click outside it. By default your search results vanish. We fix this with chrome.storage.session:
chrome.storage.session.set({ ckf_session_state: {
keyword, matches, currentIndex, color, status
}});
Clears when Chrome closes so you never get stale results from yesterday.
How I built it
- React + TypeScript for the popup UI, components stay small and the types catch a lot of bugs early, especially around the Chrome messaging API where the wrong payload shape is easy to miss
-
Vite to compile the React code into plain JS the browser can actually run. JSX doesn't run natively in Chrome so Vite bundles everything into a single file that the extension loads. I also added a small custom Vite plugin to copy
content.jsandmanifest.jsoninto the output folder since Vite only bundles what's imported, and those files sit outside the React tree entirely - Tailwind CSS for styling — useful for an extension popup where you're tweaking a lot of small spacing and colour values and don't want a separate stylesheet to maintain
- Chrome MV3 — the current extension standard, no remote code execution
Try it
The extension is live on the Chrome Web Store:
If you work on vendor tools, internal dashboards, or any page where content is dense and Ctrl+F just doesn't cut it, give it a try and let me know if it actually helps 🙌
Top comments (1)
bro this is actually sick, been waiting for something like this for ages lol
couple things i'd love to see tho:
regex support - sometimes i need to search for patterns not just plain words (like UUIDs, error codes, timestamps etc). you're already using RegExp under the hood so it shouldn't be too far off?
keyboard shortcut - clicking the extension icon every time breaks the flow. even just Alt+K or something configurable via
commandsin manifest would be 🙌shadow DOM - ran into this with a few web component-heavy tools, the search just misses everything inside shadow roots. would need to recursively pierce into
element.shadowRootbut would make it way more useful on modern appscontenteditable - you handle input/textarea already which is great, but editors like Notion or anything ProseMirror-based use contenteditable divs so they're totally invisible rn. a quick
querySelectorAll('[contenteditable]')pass would probably cover most casesanyway solid work, the ancestor chain navigation is a genuinely clever UX idea