DEV Community

Cover image for How to Inject UI Into Any Website With a Chrome Extension
Anoop Kumar
Anoop Kumar

Posted on

How to Inject UI Into Any Website With a Chrome Extension

Injecting UI into a page you don't control is one of the most powerful things a Chrome extension can do. It is also one of the most fragile — platforms update their DOM, your selectors break, and your injected elements disappear. Here is the approach that works reliably for TokenPulse across five different AI platforms.

The core challenge

Modern web applications are single-page apps. The DOM mutates constantly as routes change and components re-render. An element you injected at page load may be removed and recreated by the app's JavaScript five seconds later.

The naive approach — inject on DOMContentLoaded and hope — works on static pages but fails on SPAs.

The MutationObserver pattern

The reliable approach uses a MutationObserver to watch for the target element appearing in the DOM, inject when it appears, and re-inject if it disappears:

const INJECTION_ID = 'tp-bar-container'

function inject(targetElement) {
  // Already injected — do not double inject
  if (document.getElementById(INJECTION_ID)) return

  const container = document.createElement('div')
  container.id = INJECTION_ID
  container.innerHTML = buildBarHTML()

  // Insert before the target element
  targetElement.parentNode.insertBefore(container, targetElement)
}

function findAndInject() {
  const target = findTargetElement()
  if (target) inject(target)
}

function startObserver() {
  const observer = new MutationObserver(() => {
    findAndInject()
  })

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

  // Also try immediately in case element already exists
  findAndInject()
}

document.addEventListener('DOMContentLoaded', startObserver)
// Also start on load for apps that render after DOMContentLoaded
window.addEventListener('load', startObserver)
Enter fullscreen mode Exit fullscreen mode

Finding the target element reliably

Target elements move between platform versions. Use a priority list of selectors with fallbacks:

const TARGET_SELECTORS = {
  claude: [
    '[data-testid="chat-input-area"]',
    '.ProseMirror',
    '[contenteditable="true"]',
    'div[class*="InputArea"]',
  ],
  chatgpt: [
    '#prompt-textarea',
    '[data-id="prompt-textarea"]',
    'div[contenteditable="true"][class*="prose"]',
  ],
  gemini: [
    '.input-area-container',
    '[data-testid="input-area"]',
    'rich-textarea',
  ],
}

function findTargetElement() {
  const platform = detectPlatform()
  const selectors = TARGET_SELECTORS[platform] || []

  for (const selector of selectors) {
    const el = document.querySelector(selector)
    if (el) return el
  }
  return null
}

function detectPlatform() {
  const host = window.location.hostname
  if (host.includes('claude.ai')) return 'claude'
  if (host.includes('chatgpt.com')) return 'chatgpt'
  if (host.includes('gemini.google.com')) return 'gemini'
  return 'unknown'
}
Enter fullscreen mode Exit fullscreen mode

Isolating your styles

Your injected CSS will conflict with the host page's styles unless you isolate it. Two approaches:

Approach 1: Shadow DOM (strongest isolation)

function inject(target) {
  const host = document.createElement('div')
  host.id = INJECTION_ID

  // Attach shadow root
  const shadow = host.attachShadow({ mode: 'closed' })

  // Add styles inside shadow
  const style = document.createElement('style')
  style.textContent = `
    .tp-bar {
      height: 4px;
      background: #1C1C22;
      border-radius: 2px;
      overflow: hidden;
      margin-bottom: 4px;
    }
    .tp-bar-fill {
      height: 100%;
      background: linear-gradient(90deg, #00b87a, #00E5A0);
      border-radius: 2px;
      transition: width 0.8s ease;
    }
  `
  shadow.appendChild(style)

  const bar = document.createElement('div')
  bar.className = 'tp-bar'
  bar.innerHTML = '<div class="tp-bar-fill" id="tp-fill"></div>'
  shadow.appendChild(bar)

  target.parentNode.insertBefore(host, target)
}
Enter fullscreen mode Exit fullscreen mode

Shadow DOM means the host page's CSS cannot reach your elements and your CSS cannot leak out. The trade-off: you cannot use external CSS files easily.

Approach 2: Scoped class prefixes (simpler)

Prefix every class name with a unique string and use high-specificity selectors:

/* Injected via style element */
#tp-bar-container .tp-bar {
  all: initial; /* Reset inherited styles */
  display: block;
  height: 4px;
  background: #1C1C22;
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

all: initial resets all inherited CSS properties to their initial values — effective at preventing host page styles from bleeding in.

Keeping the injection alive

SPAs often remove and recreate sections of the DOM when navigating between conversations. Your injection gets deleted along with the old DOM nodes.

The MutationObserver callback fires on every DOM change — including the deletion of your injected element — so findAndInject() naturally re-injects when needed.

But be careful about performance. MutationObserver callbacks can fire hundreds of times per second on active pages. Add a debounce:

let debounceTimer = null

const observer = new MutationObserver(() => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(findAndInject, 100)
})
Enter fullscreen mode Exit fullscreen mode

100ms debounce means the injection check runs at most 10 times per second — fast enough to feel instant, slow enough not to impact page performance.

Updating injected content

Once injected, update the bar content without re-injecting:

function updateBar(pct) {
  // Target element inside shadow DOM or regular DOM
  const fill = document.getElementById('tp-fill')
    || document.querySelector('#tp-bar-container .tp-bar-fill')

  if (!fill) return // Not injected yet

  fill.style.width = `${Math.min(100, Math.max(0, pct * 100))}%`

  // Update color based on percentage
  if (pct >= 0.9) {
    fill.style.background = '#EF4444' // red
  } else if (pct >= 0.75) {
    fill.style.background = '#F59E0B' // yellow
  } else {
    fill.style.background = 'linear-gradient(90deg, #00b87a, #00E5A0)' // green
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling route changes in SPAs

Claude and ChatGPT use client-side routing. When you navigate to a new conversation, the URL changes but the page does not reload. Your content script stays alive but the DOM changes significantly.

Listen for popstate and pushState events:

// Monkey-patch pushState to detect programmatic navigation
const originalPushState = history.pushState

history.pushState = function(...args) {
  originalPushState.apply(this, args)
  // Small delay for the app to render the new route
  setTimeout(findAndInject, 500)
}

window.addEventListener('popstate', () => {
  setTimeout(findAndInject, 500)
})
Enter fullscreen mode Exit fullscreen mode

The 500ms delay gives the SPA time to render the new route's DOM before you attempt injection.

Cleaning up on unload

Remove your injected elements when the content script unloads to avoid memory leaks:

window.addEventListener('beforeunload', () => {
  const container = document.getElementById(INJECTION_ID)
  if (container) container.remove()
  observer.disconnect()
})
Enter fullscreen mode Exit fullscreen mode

The complete pattern

const INJECTION_ID = 'tp-bar-container'
let observer = null
let debounceTimer = null

function findAndInject() {
  const target = findTargetElement()
  if (target && !document.getElementById(INJECTION_ID)) {
    inject(target)
  }
}

function startObserving() {
  if (observer) observer.disconnect()

  observer = new MutationObserver(() => {
    clearTimeout(debounceTimer)
    debounceTimer = setTimeout(findAndInject, 100)
  })

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

  findAndInject()
}

document.addEventListener('DOMContentLoaded', startObserving)
window.addEventListener('load', startObserving)

// Handle SPA navigation
const originalPushState = history.pushState
history.pushState = function(...args) {
  originalPushState.apply(this, args)
  setTimeout(findAndInject, 500)
}
window.addEventListener('popstate', () => setTimeout(findAndInject, 500))
Enter fullscreen mode Exit fullscreen mode

This pattern has kept TokenPulse's bar injected through dozens of Claude and ChatGPT updates without manual selector fixes.


Full implementation at github.com/anu-ship-it/TokenPulse.
TokenPulse — free token tracker, installs in 30 seconds.

Top comments (0)