Skip to main content
Technology & EngineeringBrowser Extension227 lines

Content Scripts

Content scripts for DOM manipulation, page interaction, and isolated world execution in browser extensions

Quick Summary16 lines
You are an expert in content scripts and DOM manipulation for building browser extensions.

## Key Points

- `"document_start"` — Inject before any page scripts run (DOM not yet built).
- `"document_end"` — Inject after the DOM is complete but before subresources like images finish loading.
- `"document_idle"` (default) — Inject after `document_end`, at the browser's discretion (typically after `DOMContentLoaded`).
- Use Shadow DOM for injected UI to prevent the host page's styles from leaking into your components and vice versa.
- Prefer `document_idle` for `run_at` unless you specifically need to intercept the page before it renders.
- Scope your selectors and IDs with a unique prefix to avoid collisions with the host page's markup.
- **Polling for DOM changes with setInterval** — repeatedly querying the DOM on a timer is wasteful and imprecise; MutationObserver is the correct tool for reacting to dynamic page content.
- **Storing sensitive data in the page's DOM** — adding data attributes or hidden elements to the host page exposes extension state to the site's own scripts, creating a potential security leak.
- **Using `run_at: "document_start"` without guarding DOM access** — injecting at document_start means the body and most elements do not exist yet; any immediate querySelector call will return null.
- Modifying the DOM before it is ready when using `run_at: "document_start"` — always check that target elements exist or use `MutationObserver`.
skilldb get browser-extension-skills/Content ScriptsFull skill: 227 lines
Paste into your CLAUDE.md or agent config

Content Scripts — Browser Extension Development

You are an expert in content scripts and DOM manipulation for building browser extensions.

Overview

Content scripts are JavaScript and CSS files that run in the context of web pages. They can read and modify the DOM of pages the user visits, but execute in an isolated world separate from the page's own scripts. This isolation prevents conflicts while still allowing full DOM access.

Core Concepts

Declaring Content Scripts

Static content scripts are declared in the manifest:

{
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*", "https://docs.google.com/*"],
      "exclude_matches": ["https://example.com/admin/*"],
      "js": ["lib/utilities.js", "content.js"],
      "css": ["content.css"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ]
}

run_at values:

  • "document_start" — Inject before any page scripts run (DOM not yet built).
  • "document_end" — Inject after the DOM is complete but before subresources like images finish loading.
  • "document_idle" (default) — Inject after document_end, at the browser's discretion (typically after DOMContentLoaded).

Programmatic Injection

Inject scripts on demand using the Scripting API:

// From background service worker or popup
chrome.scripting.executeScript({
  target: { tabId: tabId, allFrames: false },
  files: ['content.js']
});

// Inject an inline function
chrome.scripting.executeScript({
  target: { tabId: tabId },
  func: (selector, value) => {
    const el = document.querySelector(selector);
    if (el) el.textContent = value;
  },
  args: ['#status', 'Updated by extension']
});

Isolated World

Content scripts share the DOM with the page but have their own JavaScript environment:

// content.js — this variable does not collide with page variables
const config = { enabled: true };

// DOM access works normally
const heading = document.querySelector('h1');
console.log('Page heading:', heading?.textContent);

// But page JS globals are NOT accessible
// window.pageAppVariable is undefined here

Accessing Page JavaScript Context

To interact with the page's JS environment, inject a script element into the main world:

// MV3 approach: use the MAIN world
chrome.scripting.executeScript({
  target: { tabId: tabId },
  world: 'MAIN',
  func: () => {
    // This runs in the page's JS context
    console.log(window.somePageVariable);
  }
});

// From within a content script, use a script tag (less preferred)
const script = document.createElement('script');
script.textContent = `console.log('Running in page context');`;
document.documentElement.appendChild(script);
script.remove();

Implementation Patterns

Waiting for Dynamic Elements

Many modern pages render content dynamically. Use MutationObserver to wait for elements:

function waitForElement(selector, timeout = 10000) {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(selector);
    if (existing) return resolve(existing);

    const observer = new MutationObserver((mutations, obs) => {
      const el = document.querySelector(selector);
      if (el) {
        obs.disconnect();
        resolve(el);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    setTimeout(() => {
      observer.disconnect();
      reject(new Error(`Timeout waiting for ${selector}`));
    }, timeout);
  });
}

// Usage
const feedContainer = await waitForElement('.feed-items');
feedContainer.classList.add('ext-enhanced');

Shadow DOM Traversal

Some pages use Shadow DOM. Content scripts can still traverse open shadow roots:

function querySelectorDeep(selector, root = document) {
  const result = root.querySelector(selector);
  if (result) return result;

  const allElements = root.querySelectorAll('*');
  for (const el of allElements) {
    if (el.shadowRoot) {
      const found = querySelectorDeep(selector, el.shadowRoot);
      if (found) return found;
    }
  }
  return null;
}

Injecting UI Into the Page

Insert extension UI while avoiding style conflicts:

function createOverlay() {
  const host = document.createElement('div');
  host.id = 'my-extension-root';
  const shadow = host.attachShadow({ mode: 'closed' });

  shadow.innerHTML = `
    <style>
      .panel {
        position: fixed;
        top: 10px;
        right: 10px;
        width: 320px;
        padding: 16px;
        background: #ffffff;
        border: 1px solid #e0e0e0;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        z-index: 2147483647;
        font-family: system-ui, sans-serif;
      }
      button { padding: 8px 16px; cursor: pointer; }
    </style>
    <div class="panel">
      <h3>My Extension</h3>
      <p>Extension content here</p>
      <button id="close-btn">Close</button>
    </div>
  `;

  shadow.getElementById('close-btn').addEventListener('click', () => host.remove());
  document.body.appendChild(host);
}

Best Practices

  • Use Shadow DOM for injected UI to prevent the host page's styles from leaking into your components and vice versa.
  • Prefer document_idle for run_at unless you specifically need to intercept the page before it renders.
  • Scope your selectors and IDs with a unique prefix to avoid collisions with the host page's markup.

Core Philosophy

Content scripts are guests in someone else's house. The page's DOM belongs to the site, and your extension is a careful visitor that reads, augments, or modifies it without breaking the host experience. Every DOM manipulation should be defensive — elements may not exist, may be removed at any time, and may have event handlers that conflict with yours. Write content scripts as if the page is actively hostile to your presence.

Isolation is your greatest asset. The isolated world prevents collisions between your variables and the page's, but it also means you cannot assume anything about the page's JavaScript state. When you need to interact with page-level JavaScript, do so through well-defined injection points (MAIN world scripts, postMessage bridges) rather than fragile hacks. Keep the surface area of your page interaction as small as possible.

Respect the user's browsing experience above all. Content scripts that inject heavy UI, block interactions, or cause layout shifts erode trust. Use Shadow DOM for injected elements to prevent style collisions, inject CSS sparingly, and always provide a way for users to dismiss or disable your modifications. The best content scripts are invisible until the user needs them.

Anti-Patterns

  • Querying the DOM without null checks — assuming document.querySelector will always return an element leads to crashes on pages where the expected markup is absent or dynamically loaded after your script runs.

  • Injecting styles into the page's global scope — adding <style> tags or inline styles without Shadow DOM encapsulation causes visual breakage on pages with conflicting selectors or aggressive CSS resets.

  • Polling for DOM changes with setInterval — repeatedly querying the DOM on a timer is wasteful and imprecise; MutationObserver is the correct tool for reacting to dynamic page content.

  • Storing sensitive data in the page's DOM — adding data attributes or hidden elements to the host page exposes extension state to the site's own scripts, creating a potential security leak.

  • Using run_at: "document_start" without guarding DOM access — injecting at document_start means the body and most elements do not exist yet; any immediate querySelector call will return null.

Common Pitfalls

  • Attempting to access variables from the page's JavaScript context directly — content scripts run in an isolated world and cannot see window properties set by the page without using MAIN world injection.
  • Modifying the DOM before it is ready when using run_at: "document_start" — always check that target elements exist or use MutationObserver.

Install this skill directly: skilldb add browser-extension-skills

Get CLI access →