DEV Community

Khiem Truong
Khiem Truong

Posted on

I Got Tired of Ctrl+F on Dense Pages, So I Built a Chrome Extension

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>
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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>]
}
Enter fullscreen mode Exit fullscreen mode

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, { ... });
}
Enter fullscreen mode Exit fullscreen mode

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, ... });
  }
});
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}});
Enter fullscreen mode Exit fullscreen mode

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.js and manifest.json into 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:

👉 Container Keyword Finder

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)

Collapse
 
detautama profile image
Deta Utama

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 commands in 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.shadowRoot but would make it way more useful on modern apps

  • contenteditable - 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 cases

anyway solid work, the ancestor chain navigation is a genuinely clever UX idea