Chrome Extension Dynamic Content Injection — Best Practices

5 min read

Dynamic Content Injection

Dynamic content injection enables Chrome Extensions to inject floating widgets, toolbars, sidebars, and other UI elements directly into web pages from content scripts. This pattern is fundamental for creating rich, interactive extensions that integrate seamlessly with host pages.

Overview

Content scripts can inject UI elements into pages, but doing so safely requires careful consideration of:

See also: Content Script Isolation, Shadow DOM Advanced, Content Script Patterns


Floating Widget Pattern

Position Fixed Overlay

Floating widgets use position: fixed to remain relative to the viewport:

// Inject a floating action button
const widget = document.createElement('div');
widget.id = 'my-extension-fab';
widget.style.cssText = `
  position: fixed;
  bottom: 24px;
  right: 24px;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: #6366f1;
  color: white;
  cursor: pointer;
  z-index: 2147483647;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
widget.textContent = '+';
document.body.appendChild(widget);

Draggable Panel

function makeDraggable(element: HTMLElement): void {
  let isDragging = false;
  let offsetX = 0, offsetY = 0;

  element.addEventListener('mousedown', (e) => {
    isDragging = true;
    offsetX = e.clientX - element.getBoundingClientRect().left;
    offsetY = e.clientY - element.getBoundingClientRect().top;
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    element.style.left = `${e.clientX - offsetX}px`;
    element.style.top = `${e.clientY - offsetY}px`;
  });

  document.addEventListener('mouseup', () => isDragging = false);
}

Shadow DOM for Style Isolation

Shadow DOM prevents page CSS from affecting your injected UI:

function createIsolatedWidget(): HTMLElement {
  const container = document.createElement('div');
  container.id = 'my-extension-widget';
  
  const shadow = container.attachShadow({ mode: 'open' });
  
  const style = document.createElement('style');
  style.textContent = `
    :host {
      position: fixed;
      top: 20px;
      right: 20px;
      font-family: system-ui, sans-serif;
      z-index: 999999;
    }
    .widget {
      background: white;
      border-radius: 8px;
      padding: 16px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }
    @media (prefers-color-scheme: dark) {
      .widget { background: #1e1e1e; color: #fff; }
    }
  `;
  
  const widget = document.createElement('div');
  widget.className = 'widget';
  widget.textContent = 'Hello from extension!';
  
  shadow.appendChild(style);
  shadow.appendChild(widget);
  document.body.appendChild(container);
  
  return container;
}

CSS Injection Methods

chrome.scripting.insertCSS (Manifest V3)

await chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: '.my-extension-class { color: red; }'
});

Style Element Creation

For dynamic styles with page-specific logic:

function injectStyles(css: string): HTMLStyleElement {
  const style = document.createElement('style');
  style.textContent = css;
  document.head.appendChild(style);
  return style;
}

Use chrome.scripting for static CSS; use style elements for dynamic, runtime-generated styles.


Z-Index Management

Chrome extensions should use z-index values in the safe range:

Value Range Usage
0 - 9999 Page content
10000000+ Browser chrome (address bar, etc.)
2147483647 Maximum safe value for page elements
const Z_INDEX_SAFE = 2147483640; // Leave room for edge cases
element.style.zIndex = String(Z_INDEX_SAFE);

Handling Page Navigation

Single-page applications (SPAs) don’t trigger page loads. Use the navigation API or polling:

// Using chrome.webNavigation (requires permission)
chrome.webNavigation.onCompleted.addListener(() => {
  initializeExtension();
});

// Fallback: MutationObserver for SPAs
const observer = new MutationObserver(() => {
  if (!document.getElementById('my-extension-widget')) {
    createIsolatedWidget();
  }
});
observer.observe(document.body, { childList: true, subtree: true });

Clean Removal

Remove all injected content when the extension disables or the script unloads:

function cleanup(): void {
  const elements = document.querySelectorAll('[id^="my-extension-"]');
  elements.forEach(el => el.remove());
}

// Listen for extension disable
chrome.runtime.onSuspend.addListener(cleanup);
window.addEventListener('unload', cleanup);

When to Use iframe vs Direct DOM

Approach Use Case
Direct DOM Floating buttons, toolbars, sidebars - full page integration
iframe Isolated third-party content, complex iframe-friendly libraries

For most extension UI, direct DOM with Shadow DOM provides the best balance of integration and isolation. -e —

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