Mastering Content Scripts in Chrome Extensions — Developer Guide

13 min read

Mastering Content Scripts in Chrome Extensions

Content scripts are the bridge between your Chrome extension and web pages. They run in the context of web pages, allowing you to read and modify the DOM, respond to user interactions, and inject custom styles. This guide covers everything you need to become proficient with content scripts in Chrome extensions.

Overview

Content scripts are JavaScript files that execute within the context of web pages. They can access the DOM, modify page content, and respond to user actions, but they run in an isolated environment separate from the page’s JavaScript and other extensions.

Injection Methods

Manifest-Based (Static) Injection

The simplest approach declares scripts in manifest.json under the content_scripts key. These scripts automatically execute on matching pages.

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "css": ["styles/content.css"],
      "run_at": "document_idle"
    }
  ]
}

Key properties:

Programmatic Injection

For dynamic control, use the chrome.scripting API. This requires the scripting permission.

{
  "permissions": ["scripting", "activeTab"]
}
// Inject when user clicks the extension icon
chrome.action.onClicked.addListener(async (tab) => {
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ['content.js']
  });

  await chrome.scripting.insertCSS({
    target: { tabId: tab.id },
    files: ['styles/content.css']
  });
});

You can also inject inline code:

await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: () => {
    document.body.classList.add('extension-active');
  }
});

Isolated Worlds

Chrome extensions operate in two distinct JavaScript worlds:

Isolated World (Default)

Content scripts run in an isolated environment by default. They cannot access page variables, and the page cannot access extension variables.

// Content script - isolated world
const pageTitle = document.title;  // Works - can read DOM
// const pageVariable = window.pageVariable;  // Would fail - can't access page JS

Main World

The world property allows scripts to run in the same context as the page:

{
  "content_scripts": [{
    "matches": ["https://*.example.com/*"],
    "js": ["content.js"],
    "world": "MAIN"
  }]
}
// With world: "MAIN", you can access page variables
const pageVariable = window.somePageFunction();

Warning: Running in the main world exposes your extension code to the page and vice versa. Use this only when necessary and sanitize all inputs.

DOM Access Patterns

Basic DOM Manipulation

// Reading DOM content
const heading = document.querySelector('h1');
const text = heading.textContent;

// Modifying the DOM
const newElement = document.createElement('div');
newElement.textContent = 'Hello, World!';
newElement.className = 'extension-element';
document.body.appendChild(newElement);

// Listening for events
document.addEventListener('click', (event) => {
  console.log('Clicked:', event.target);
});

Waiting for Elements

// MutationObserver for dynamic content
const observer = new MutationObserver((mutations) => {
  const element = document.querySelector('.dynamic-element');
  if (element && !element.dataset.processed) {
    element.dataset.processed = 'true';
    processElement(element);
  }
});

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

// Simple waiting function
async function waitForSelector(selector, timeout = 5000) {
  return new Promise((resolve, reject) => {
    if (document.querySelector(selector)) {
      return resolve(document.querySelector(selector));
    }

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

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

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

// Usage
const element = await waitForSelector('.lazy-loaded-content');

Handling Shadow DOM

Content scripts can access elements inside open shadow DOM:

// Access shadow DOM
const hostElement = document.querySelector('#shadow-host');
if (hostElement && hostElement.shadowRoot) {
  const shadowContent = hostElement.shadowRoot.querySelector('.inner');
  shadowContent.textContent = 'Modified from extension!';
}

// Inject into shadow DOM
const style = document.createElement('style');
style.textContent = `
  .highlight {
    background: yellow;
    color: black;
  }
`;

// Inject into open shadow root
const newElement = document.createElement('div');
newElement.textContent = 'Inside shadow DOM';
newElement.className = 'highlight';

const shadowRoot = hostElement.shadowRoot;
shadowRoot.appendChild(style);
shadowRoot.appendChild(newElement);

CSS Injection

Static CSS via Manifest

{
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "css": ["styles/base.css"]
  }]
}

Programmatic CSS Injection

await chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: `
    .extension-highlight {
      background-color: yellow;
      border: 2px solid orange;
    }
  `
});

Dynamic Style Management

// Inject styles dynamically
function injectStyles(css) {
  const style = document.createElement('style');
  style.id = 'extension-dynamic-styles';
  style.textContent = css;
  document.head.appendChild(style);
}

// Remove injected styles
function removeStyles() {
  const style = document.getElementById('extension-dynamic-styles');
  if (style) {
    style.remove();
  }
}

Run_at Timing

The run_at property controls when content scripts execute:

Value Timing
document_start Before any DOM is constructed
document_end After DOM is complete, before subresources
document_idle After page fully loads (default)
{
  "content_scripts": [{
    "matches": ["https://*.example.com/*"],
    "js": ["early-inject.js"],
    "run_at": "document_start"
  }]
}
// document_start - inject CSS immediately
document.addEventListener('DOMContentLoaded', () => {
  document.documentElement.style.setProperty('--extension-color', 'blue');
});

Matching Patterns

Basic Patterns

{
  "content_scripts": [{
    "matches": [
      "<all_urls>",                    // All URLs
      "https://*.example.com/*",       // Any subdomain of example.com
      "https://example.com/path/*",    // Specific path
      "https://example.com/*",         // Exact domain
      "file:///path/to/file.html"     // Local files
    ]
  }]
}

Exclude Patterns

{
  "content_scripts": [{
    "matches": ["https://*.example.com/*"],
    "exclude_matches": ["https://admin.example.com/*"]
  }]
}

Match Origin and Paths

{
  "content_scripts": [{
    "match_about_blank": true,
    "matches": ["https://example.com/*"]
  }]
}

Dynamic Content Script Registration

Register content scripts dynamically at runtime:

// Register a dynamic content script
async function registerDynamicScript() {
  await chrome.scripting.registerContentScripts([{
    id: 'dynamic-script',
    matches: ['https://*.example.com/*'],
    js: ['content.js'],
    css: ['styles.css'],
    run_at: 'document_idle'
  }]);
}

// Unregister
async function unregisterDynamicScript() {
  await chrome.scripting.unregisterContentScripts(['dynamic-script']);
}

// Get registered scripts
async function getRegisteredScripts() {
  const scripts = await chrome.scripting.getRegisteredContentScripts();
  console.log(scripts);
}

Communicating with the Page Context

Using window.postMessage

From content script to page:

// Content script - send message to page
window.postMessage({
  type: 'EXTENSION_MESSAGE',
  payload: { action: 'highlight', color: 'yellow' }
}, '*');

// Content script - receive from page
window.addEventListener('message', (event) => {
  if (event.source === window && event.data.type === 'PAGE_MESSAGE') {
    console.log('Received from page:', event.data.payload);
  }
});

In page script (injected):

// Page script - receive from extension
window.addEventListener('message', (event) => {
  if (event.data.type === 'EXTENSION_MESSAGE') {
    // Handle extension message
  }
});

// Page script - send to extension
window.postMessage({
  type: 'PAGE_MESSAGE',
  payload: { data: 'hello' }
}, '*');

Using Custom Events

// Content script - dispatch custom event
const event = new CustomEvent('extension-action', {
  detail: { action: 'process', data: {...} }
});
document.dispatchEvent(event);

// Page script listens
document.addEventListener('extension-action', (e) => {
  console.log('Extension action:', e.detail);
});

Injecting a Script for Communication

// Inject a bridge script into the main world
await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => {
    // This runs in the page's context
    window.extensionBridge = {
      sendToExtension: (data) => {
        window.postMessage({
          type: 'FROM_PAGE',
          payload: data
        }, '*');
      }
    };

    window.addEventListener('message', (event) => {
      if (event.data.type === 'TO_PAGE') {
        // Handle message from extension
      }
    });
  }
});

Best Practices

  1. Use isolated worlds by default - Only use world: "MAIN" when necessary
  2. Minimize manifest permissions - Request only what’s needed
  3. Handle page dynamics - Use MutationObserver for SPAs and dynamic content
  4. Clean up properly - Remove injected styles, event listeners, and observers when no longer needed
  5. Avoid conflicts - Use unique class names and IDs with prefixes

Common Pitfalls



Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.

No previous article
No next article