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)
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'
}
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)
}
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;
/* ... */
}
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)
})
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
}
}
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)
})
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()
})
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))
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)