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) 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 = '' 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.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to