Chrome Scripting API: Programmatic Script and CSS Injection — Developer Guide

18 min read

Chrome Scripting API: Programmatic Script and CSS Injection

The Chrome Scripting API (chrome.scripting) is the cornerstone of programmatic injection in Manifest V3 extensions. It provides fine-grained control over when and how JavaScript and CSS are injected into web pages, replacing the deprecated chrome.tabs.executeScript and chrome.tabs.insertCSS methods from Manifest V2.

Overview

The Scripting API enables extensions to:

Required Permission

To use the Scripting API, add the scripting permission to your manifest.json:

{
  "permissions": ["scripting"]
}

For injections that only affect the active tab (user-initiated), you can use the activeTab permission instead, which doesn’t require host permissions:

{
  "permissions": ["activeTab", "scripting"]
}

executeScript: Injecting JavaScript

The chrome.scripting.executeScript() method is the primary way to inject JavaScript into web pages. It supports two injection methods: files and functions.

Injecting Files

Inject a JavaScript file that exists in your extension:

// Inject a content script file into a specific tab
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  files: ['content-script.js']
}, (results) => {
  if (chrome.runtime.lastError) {
    console.error(chrome.runtime.lastError);
    return;
  }
  console.log('Script injected:', results);
});

Injecting Functions

Inject an inline function that executes in the target page context:

// Inject a function (serialized and executed in page context)
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: () => {
    // This runs in the page's context
    return document.title;
  }
}, (results) => {
  console.log('Page title:', results[0].result);
});

When using functions, you can also pass arguments that will be serialized:

// Pass arguments to the injected function
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: (message, color) => {
    const div = document.createElement('div');
    div.textContent = message;
    div.style.backgroundColor = color;
    div.style.padding = '10px';
    div.style.position = 'fixed';
    div.style.top = '0';
    div.style.right = '0';
    div.style.zIndex = '999999';
    document.body.appendChild(div);
    return div.id;
  },
  args: ['Hello from extension!', '#ff0000']
});

Return Values

The executeScript method returns an array of results, one per frame:

chrome.scripting.executeScript({
  target: { tabId: tab.id, allFrames: true },
  func: () => window.location.href
}, (results) => {
  results.forEach((result, frameIndex) => {
    console.log(`Frame ${frameIndex}: ${result.result}`);
  });
});

insertCSS and removeCSS: Managing Styles

The Scripting API provides methods to inject and remove CSS stylesheets.

insertCSS: Injecting Styles

Inject CSS from a file:

// Inject a CSS file
chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  files: ['styles/injected.css']
});

Inject inline CSS:

// Inject inline CSS
chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: `
    .extension-highlight {
      background-color: yellow !important;
    }
    .extension-overlay {
      position: fixed;
      top: 0;
      right: 0;
      background: rgba(0, 0, 0, 0.8);
      color: white;
      padding: 10px;
      z-index: 999999;
    }
  `
});

removeCSS: Removing Styles

Remove injected CSS styles:

// Remove injected CSS by specifying the same content
chrome.scripting.removeCSS({
  target: { tabId: tab.id },
  css: '.extension-highlight { background-color: yellow !important; }'
});

// Remove all CSS from a file (must match exactly what was inserted)
chrome.scripting.removeCSS({
  target: { tabId: tab.id },
  files: ['styles/injected.css']
});

CSS Injection with runAt

Control when CSS is injected:

// Inject CSS before the page loads
chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: 'body { opacity: 0; }',
  injectImmediately: true
});

Injection Targets: Tabs, Frames, and All Frames

The Scripting API provides granular control over where scripts are injected.

Target Specific Tab

// Inject into a specific tab by ID
chrome.scripting.executeScript({
  target: { tabId: 123 },
  files: ['content.js']
});

Target All Frames in a Tab

// Inject into all frames in a tab
chrome.scripting.executeScript({
  target: { tabId: tab.id, allFrames: true },
  files: ['content.js']
}, (results) => {
  console.log(`Injected into ${results.length} frames`);
});

Target Specific Frames

// Inject into specific frame IDs
chrome.scripting.executeScript({
  target: { tabId: tab.id, frameIds: [0, 2, 5] },
  files: ['frame-script.js']
});

Get Frame IDs First

// Get all frame IDs in a tab
chrome.webNavigation.getAllFrames({ tabId: tab.id }, (frames) => {
  const frameIds = frames.map(f => f.frameId);
  
  chrome.scripting.executeScript({
    target: { tabId: tab.id, frameIds: frameIds },
    func: () => console.log('Injected!')
  });
});

World Targeting: MAIN vs ISOLATED

Chrome extensions operate in two distinct JavaScript worlds.

ISOLATED World (Default)

Content scripts run in an isolated world by default—they can access the DOM but cannot see page JavaScript variables:

// Default: runs in ISOLATED world
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'ISOLATED',  // This is the default
  func: () => {
    // Can access DOM
    const title = document.title;
    const links = document.querySelectorAll('a');
    
    // Cannot access page JavaScript variables
    // window.pageVariable would be undefined
  }
});

MAIN World

The MAIN world allows scripts to access page JavaScript variables and be accessed by page scripts:

// Run in the page's MAIN world
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => {
    // Can access page variables
    console.log(window.pageData);
    
    // Page scripts can call this
    window.extensionFunction = () => {
      return 'Called from page!';
    };
  }
});

// Later, the page can call this function
// window.extensionFunction() // Returns 'Called from page!'

Use Cases for MAIN World

// Use MAIN world to:
// 1. Read page JavaScript variables
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => window.reactStore?.state
});

// 2. Access page's localStorage/sessionStorage
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => localStorage.getItem('authToken')
});

// 3. Call page functions
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => window.someGlobalFunction()
});

// 4. Expose functions to page
chrome.scripting.executeScript({
  target: { tabId: tab.id },
  world: 'MAIN',
  func: () => {
    window.extendPage = {
      notify: (msg) => alert(msg),
      getSelection: () => window.getSelection().toString()
    };
  }
});

registerContentScripts: Dynamic Registration

The chrome.scripting.registerContentScripts() method registers content scripts at runtime, providing an alternative to static declarations in manifest.json.

Registering Scripts Dynamically

// Register content scripts at runtime
chrome.scripting.registerContentScripts([{
  id: 'my-content-script',
  matches: ['https://*.example.com/*'],
  js: ['content.js'],
  css: ['styles.css'],
  runAt: 'document_idle',
  world: 'ISOLATED',
  matchOriginAsFallback: true
}], () => {
  if (chrome.runtime.lastError) {
    console.error('Registration failed:', chrome.runtime.lastError);
  } else {
    console.log('Scripts registered successfully');
  }
});

Unregistering Scripts

// Unregister scripts by ID
chrome.scripting.unregisterContentScripts(['my-content-script'], () => {
  console.log('Scripts unregistered');
});

// Unregister all scripts
chrome.scripting.unregisterContentScripts(() => {
  console.log('All scripts unregistered');
});

Getting Registered Scripts

// Get all registered content scripts
chrome.scripting.getRegisteredContentScripts((scripts) => {
  console.log('Registered scripts:', scripts);
});

Update Scripts

// Update existing scripts
chrome.scripting.updateContentScripts([{
  id: 'my-content-script',
  css: ['updated-styles.css']
}]);

registerContentScripts vs Manifest content_scripts

There are two ways to declare content scripts: static (manifest) and dynamic (registerContentScripts).

Static Declaration (Manifest)

{
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ]
}

Pros:

Cons:

Dynamic Registration (registerContentScripts)

// Register at runtime based on conditions
async function registerForDomain(domain) {
  await chrome.scripting.registerContentScripts([{
    id: `script-${domain}`,
    matches: [`https://*.${domain}/*`],
    js: ['content.js'],
    runAt: 'document_idle'
  }]);
}

Pros:

Cons:

When to Use Each Approach

Scenario Recommended Approach
Always needed on specific sites Static manifest
User-configurable site list Dynamic registration
Context-aware injection Dynamic registration
Simple extension Static manifest
Complex conditional logic Dynamic registration

Persisting Dynamic Scripts Across Sessions

Since registerContentScripts doesn’t persist across browser restarts:

// On extension startup, restore registered scripts
chrome.runtime.onStartup.addListener(() => {
  restoreUserConfiguredScripts();
});

// Also restore on installation/update
chrome.runtime.onInstalled.addListener(() => {
  restoreUserConfiguredScripts();
});

function restoreUserConfiguredScripts() {
  chrome.storage.local.get(['userConfiguredDomains'], (result) => {
    const domains = result.userConfiguredDomains || [];
    domains.forEach(domain => {
      chrome.scripting.registerContentScripts([{
        id: `dynamic-${domain}`,
        matches: [`https://*.${domain}/*`],
        js: ['content.js'],
        runAt: 'document_idle'
      }]);
    });
  });
}

Replacing tabs.executeScript from MV2

If you’re migrating from Manifest V2, here’s how to replace the deprecated methods.

MV2: chrome.tabs.executeScript

// MV2 (deprecated)
chrome.tabs.executeScript(tabId, {
  file: 'content.js'
}, (results) => {
  // Handle results
});

MV3: chrome.scripting.executeScript

// MV3 (current)
chrome.scripting.executeScript({
  target: { tabId: tabId },
  files: ['content.js']
}, (results) => {
  // Handle results
});

MV2: chrome.tabs.insertCSS

// MV2 (deprecated)
chrome.tabs.insertCSS(tabId, {
  file: 'styles.css'
});

MV3: chrome.scripting.insertCSS

// MV3 (current)
chrome.scripting.insertCSS({
  target: { tabId: tabId },
  files: ['styles.css']
});

Complete Migration Example

// Before (MV2)
function injectContentScript(tabId) {
  chrome.tabs.executeScript(tabId, {
    file: 'content.js'
  }, () => {
    chrome.tabs.insertCSS(tabId, {
      file: 'styles.css'
    });
  });
}

// After (MV3)
async function injectContentScript(tabId) {
  await chrome.scripting.executeScript({
    target: { tabId: tabId },
    files: ['content.js']
  });
  
  await chrome.scripting.insertCSS({
    target: { tabId: tabId },
    files: ['styles.css']
  });
}

Using Callback-Based APIs with Promises

For compatibility with modern async/await code:

// Wrap callback-based API in a promise
function executeScript(tabId, options) {
  return new Promise((resolve, reject) => {
    chrome.scripting.executeScript({
      target: { tabId: tabId },
      ...options
    }, (results) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
      } else {
        resolve(results);
      }
    });
  });
}

// Now use with async/await
async function main() {
  const results = await executeScript(tabId, {
    func: () => document.title
  });
  console.log(results[0].result);
}

Error Handling

Proper error handling is essential when working with the Scripting API:

async function safeExecuteScript(tabId, options) {
  try {
    const results = await chrome.scripting.executeScript({
      target: { tabId: tabId },
      ...options
    });
    return results;
  } catch (error) {
    // Handle specific error types
    if (error.message.includes('No tab with id')) {
      console.error('Tab no longer exists');
    } else if (error.message.includes('Cannot access')) {
      console.error('Permission denied - check host permissions');
    } else {
      console.error('Script injection failed:', error);
    }
    throw error;
  }
}

// Common error scenarios
chrome.scripting.executeScript({
  target: { tabId: 99999 }  // Invalid tab ID
}, () => {});
// Error: No tab with id: 99999

chrome.scripting.executeScript({
  target: { tabId: tab.id },
  files: ['nonexistent.js']
}, () => {});
// Error: Could not load manifest

Best Practices

  1. Use ISOLATED world by default - Only use MAIN when you need page access
  2. Minimize permissions - Use activeTab for user-initiated actions
  3. Clean up injected content - Remove CSS and scripts when no longer needed
  4. Handle errors gracefully - Check for chrome.runtime.lastError
  5. Specify frames carefully - Avoid allFrames: true unless necessary
  6. Consider performance - Inject only what’s needed, when it’s needed

Common Pitfalls



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

No previous article
No next article