Chrome Extension Content Script CSS Injection — Complete Guide with TypeScript
21 min readChrome Extension Content Script CSS Injection — Complete Guide with TypeScript
Introduction
CSS injection in Chrome extension content scripts is one of the most common yet often misunderstood aspects of extension development. Whether you’re building a dark mode toggle, a productivity toolbar, or a visual annotation tool, understanding how to properly inject and manage CSS in the context of web pages is essential for creating reliable, conflict-free extensions.
This comprehensive guide covers everything from basic CSS injection methods in Manifest V3 to advanced techniques like Shadow DOM isolation, dynamic stylesheet management, and TypeScript-integrated solutions. You’ll learn how extensions like Tab Suspender Pro implement lightweight, performant UI overlays using these very techniques.
Understanding Content Script CSS Context
Before diving into implementation, it’s crucial to understand the unique environment content scripts operate in. Content scripts run in the context of the host page, meaning they share the DOM but have their own isolated JavaScript execution environment. This separation creates both opportunities and challenges for CSS injection.
When you inject CSS through a content script, it enters the page’s CSS cascade, potentially conflicting with existing page styles. This is why understanding injection strategies isn’t just about getting your styles to appear—it’s about doing so reliably without breaking the host page or being overridden by it.
The Isolation Challenge
Web pages today employ various techniques that can interfere with your injected styles: CSS-in-JS libraries, scoped styling systems, dynamic class names, and Shadow DOM encapsulation. A robust CSS injection strategy must account for these challenges while maintaining performance and reliability.
Method 1: Declarative CSS Injection in Manifest
The simplest approach to CSS injection uses the manifest.json file to declare stylesheets that should be automatically injected when your content script loads.
manifest.json Configuration
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": ["styles/injected.css", "styles/components.css"],
"js": ["content/index.js"]
}
]
}
This declarative approach offers simplicity but comes with limitations. The stylesheets load synchronously before the page renders, which can cause a flash of unstyled content (FOUC) in some scenarios. Additionally, you have no runtime control over when or how these styles apply.
When to Use Declarative Injection
Declarative injection works well for:
- Global style resets that must apply immediately
- Extensions targeting a specific, known set of pages
- Styles that don’t require runtime modification
Method 2: Programmatic CSS Injection
For dynamic, runtime-controlled CSS injection, programmatic approaches provide significantly more flexibility. This method allows you to inject styles conditionally, modify them based on user preferences, or apply them in response to page events.
Basic Programmatic Injection
// content/styles/injector.ts
/**
* Injects a CSS string into the page as a style element
*/
export function injectStyle(css: string, id?: string): HTMLStyleElement {
// Check if already injected
if (id) {
const existing = document.getElementById(id);
if (existing && existing instanceof HTMLStyleElement) {
return existing;
}
}
const style = document.createElement('style');
style.textContent = css;
if (id) {
style.id = id;
}
// Insert at the end of head to ensure other styles load first
(document.head || document.documentElement).appendChild(style);
return style;
}
/**
* Removes an injected style by its ID
*/
export function removeStyle(id: string): void {
const element = document.getElementById(id);
if (element && element instanceof HTMLStyleElement) {
element.remove();
}
}
/**
* Updates an existing injected style with new CSS
*/
export function updateStyle(id: string, css: string): boolean {
const element = document.getElementById(id);
if (element && element instanceof HTMLStyleElement) {
element.textContent = css;
return true;
}
return false;
}
Injecting External Stylesheets
Sometimes you need to load an external stylesheet rather than inline CSS:
// content/styles/external-injector.ts
export interface StylesheetInjectionOptions {
href: string;
id?: string;
media?: string;
onload?: () => void;
onerror?: () => void;
}
/**
* Injects an external stylesheet into the page
*/
export function injectStylesheet(
options: StylesheetInjectionOptions
): HTMLLinkElement {
const { href, id, media = 'screen', onload, onerror } = options;
// Prevent duplicate injection
if (id) {
const existing = document.getElementById(id);
if (existing && existing instanceof HTMLLinkElement) {
if (onload) {
onload();
}
return existing;
}
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.media = media;
if (id) {
link.id = id;
}
if (onload) {
link.onload = () => onload();
}
if (onerror) {
link.onerror = () => onerror();
}
(document.head || document.documentElement).appendChild(link);
return link;
}
Method 3: Shadow DOM Isolation
One of the most powerful techniques for CSS injection involves using Shadow DOM to encapsulate your extension’s UI. This approach provides complete style isolation—your styles won’t affect the host page, and the host page’s styles won’t affect your UI.
Creating an Isolated Container
// content/ui/shadow-container.ts
export interface ShadowContainerConfig {
id: string;
mode: 'open' | 'closed';
}
/**
* Creates an isolated Shadow DOM container for extension UI
*/
export class ShadowContainer {
private container: HTMLDivElement;
private shadowRoot: ShadowRoot;
constructor(private config: ShadowContainerConfig) {
this.container = this.createContainer();
this.shadowRoot = this.container.attachShadow({ mode: config.mode });
this.injectBaseStyles();
}
private createContainer(): HTMLDivElement {
// Check if container already exists
const existing = document.getElementById(this.config.id);
if (existing) {
// If exists and has shadow root, use it
if (existing.shadowRoot) {
this.shadowRoot = existing.shadowRoot as ShadowRoot;
return existing;
}
// Otherwise remove and recreate
existing.remove();
}
const container = document.createElement('div');
container.id = this.config.id;
container.style.cssText = 'all: initial;';
document.documentElement.appendChild(container);
return container;
}
private injectBaseStyles(): void {
const style = document.createElement('style');
style.textContent = `
:host {
all: initial;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
box-sizing: border-box;
}
*, *::before, *::after {
box-sizing: inherit;
}
`;
this.shadowRoot.appendChild(style);
}
/**
* Get the container element for adding your UI
*/
public getRoot(): ShadowRoot {
return this.shadowRoot;
}
/**
* Mount a custom element or HTML string to the shadow root
*/
public mount(content: HTMLElement | string): HTMLElement | null {
if (typeof content === 'string') {
const wrapper = document.createElement('div');
wrapper.innerHTML = content;
const element = wrapper.firstElementChild as HTMLElement;
if (element) {
this.shadowRoot.appendChild(element);
return element;
}
return null;
}
this.shadowRoot.appendChild(content);
return content;
}
/**
* Clean up the container
*/
public destroy(): void {
this.container.remove();
}
}
Example: Floating Toolbar with Shadow DOM
Here’s how Tab Suspender Pro implements its lightweight floating toolbar:
// content/ui/floating-toolbar.ts
import { ShadowContainer } from './shadow-container';
interface ToolbarOptions {
position: 'top' | 'bottom';
theme: 'light' | 'dark';
}
export class FloatingToolbar {
private container: ShadowContainer;
private options: ToolbarOptions;
constructor(options: Partial<ToolbarOptions> = {}) {
this.options = {
position: options.position || 'top',
theme: options.theme || 'light'
};
this.container = new ShadowContainer({
id: 'tab-suspender-toolbar',
mode: 'closed' // Use 'closed' for security, 'open' for debugging
});
this.render();
this.attach();
}
private getStyles(): string {
const { position, theme } = this.options;
return `
.toolbar {
position: fixed;
${position}: 0;
left: 0;
right: 0;
height: 48px;
background: ${theme === 'dark' ? '#1a1a1a' : '#ffffff'};
border-bottom: 1px solid ${theme === 'dark' ? '#333' : '#e0e0e0'};
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.toolbar-title {
font-weight: 600;
font-size: 14px;
color: ${theme === 'dark' ? '#fff' : '#333'};
}
.toolbar-button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: ${theme === 'dark' ? '#333' : '#f0f0f0'};
color: ${theme === 'dark' ? '#fff' : '#333'};
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.toolbar-button:hover {
background: ${theme === 'dark' ? '#444' : '#e0e0e0'};
}
.toolbar-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 10px;
background: #4CAF50;
color: white;
font-size: 11px;
font-weight: 500;
}
`;
}
private render(): void {
const { position, theme } = this.options;
const toolbarHTML = `
<style>${this.getStyles()}</style>
<div class="toolbar">
<span class="toolbar-title">Tab Suspender Pro</span>
<button class="toolbar-button" id="suspend-all">Suspend All</button>
<button class="toolbar-button" id="settings">Settings</button>
<span class="toolbar-badge" id="suspended-count">0 suspended</span>
</div>
`;
this.container.mount(toolbarHTML);
}
private attach(): void {
const root = this.container.getRoot();
// Add event listeners within Shadow DOM
const suspendBtn = root.getElementById('suspend-all');
const settingsBtn = root.getElementById('settings');
const countBadge = root.getElementById('suspended-count');
suspendBtn?.addEventListener('click', () => {
// Handle suspend all action
this.handleSuspendAll();
});
settingsBtn?.addEventListener('click', () => {
// Handle settings action
this.handleOpenSettings();
});
}
private handleSuspendAll(): void {
// Implementation for suspending all tabs
console.log('Suspending all tabs...');
}
private handleOpenSettings(): void {
// Implementation for opening settings
console.log('Opening settings...');
}
public updateSuspendedCount(count: number): void {
const root = this.container.getRoot();
const badge = root.getElementById('suspended-count');
if (badge) {
badge.textContent = `${count} suspended`;
}
}
public destroy(): void {
this.container.destroy();
}
}
Advanced Techniques
CSS Variables for Theme Support
Modern CSS injection strategies should leverage CSS custom properties for flexible theming:
// content/styles/theme-manager.ts
export type Theme = 'light' | 'dark' | 'system';
export interface ThemeVariables {
background: string;
foreground: string;
accent: string;
border: string;
shadow: string;
}
const themes: Record<Exclude<Theme, 'system'>, ThemeVariables> = {
light: {
background: '#ffffff',
foreground: '#1a1a1a',
accent: '#0066cc',
border: '#e0e0e0',
shadow: 'rgba(0, 0, 0, 0.1)'
},
dark: {
background: '#1a1a1a',
foreground: '#ffffff',
accent: '#4da6ff',
border: '#333333',
shadow: 'rgba(0, 0, 0, 0.3)'
}
};
/**
* Generates CSS custom properties for theming
*/
export function generateThemeCSS(theme: Theme, prefersDark: boolean): string {
const actualTheme = theme === 'system'
? (prefersDark ? 'dark' : 'light')
: theme;
const vars = themes[actualTheme];
return `
:root {
--ext-background: ${vars.background};
--ext-foreground: ${vars.foreground};
--ext-accent: ${vars.accent};
--ext-border: ${vars.border};
--ext-shadow: ${vars.shadow};
}
`;
}
/**
* Creates a complete stylesheet with theme support
*/
export function createThemedStylesheet(
baseStyles: string,
theme: Theme
): string {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const themeCSS = generateThemeCSS(theme, prefersDark);
return `${themeCSS}\n${baseStyles}`;
}
Conditional Injection Based on Page Content
Sometimes you need to inject styles only when certain conditions are met:
// content/styles/conditional-injector.ts
interface InjectionCondition {
urlPattern?: RegExp;
elementExists?: (doc: Document) => boolean;
cssProperty?: { element: string; property: string; value?: string };
}
export class ConditionalInjector {
private conditions: InjectionCondition[];
private injectedStyles: Map<string, HTMLStyleElement> = new Map();
constructor(conditions: InjectionCondition[]) {
this.conditions = conditions;
}
/**
* Check if all conditions are met
*/
private checkConditions(): boolean {
for (const condition of this.conditions) {
if (condition.urlPattern && !condition.urlPattern.test(window.location.href)) {
return false;
}
if (condition.elementExists && !condition.elementExists(document)) {
return false;
}
if (condition.cssProperty) {
const { element, property, value } = condition.cssProperty;
const el = document.querySelector(element);
if (!el) return false;
const computed = getComputedStyle(el).getPropertyValue(property).trim();
if (value && computed !== value) return false;
}
}
return true;
}
/**
* Inject styles if conditions are met
*/
public inject(css: string, id: string): boolean {
if (!this.checkConditions()) {
return false;
}
if (this.injectedStyles.has(id)) {
return true;
}
const style = document.createElement('style');
style.textContent = css;
style.id = id;
document.head.appendChild(style);
this.injectedStyles.set(id, style);
return true;
}
/**
* Remove injected styles
*/
public remove(id: string): void {
const style = this.injectedStyles.get(id);
if (style) {
style.remove();
this.injectedStyles.delete(id);
}
}
/**
* Remove all injected styles
*/
public removeAll(): void {
for (const [id, style] of this.injectedStyles) {
style.remove();
}
this.injectedStyles.clear();
}
}
Performance Optimization
Debouncing Style Updates
When injecting styles dynamically based on user interaction or page changes, debouncing prevents excessive DOM manipulation:
// content/styles/debounced-injector.ts
export class DebouncedStyleInjector {
private styleElement: HTMLStyleElement | null = null;
private updateTimeout: number | null = null;
private pendingCSS: string = '';
constructor(private id: string, private debounceMs: number = 100) {
this.initialize();
}
private initialize(): void {
this.styleElement = document.createElement('style');
this.styleElement.id = this.id;
document.head.appendChild(this.styleElement);
}
/**
* Queue a CSS update, debounced
*/
public update(css: string): void {
this.pendingCSS = css;
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
}
this.updateTimeout = window.setTimeout(() => {
this.apply();
}, this.debounceMs);
}
private apply(): void {
if (this.styleElement) {
this.styleElement.textContent = this.pendingCSS;
}
}
/**
* Immediately apply pending CSS without debounce
*/
public flush(): void {
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
}
this.apply();
}
/**
* Clean up
*/
public destroy(): void {
this.flush();
if (this.styleElement) {
this.styleElement.remove();
this.styleElement = null;
}
}
}
Best Practices Summary
Do’s
- Use Shadow DOM for UI components: This provides complete style isolation and prevents conflicts with host page styles
- Implement proper cleanup: Always remove injected styles when your content script disconnects
- Use CSS custom properties: They make theming and dynamic updates significantly easier
- Consider performance: Use debouncing for dynamic updates and avoid injecting unnecessary styles
- Test across page types: Verify your injection works on pages with CSS-in-JS, Shadow DOM, and aggressive style overrides
Don’ts
- Don’t rely on specific class names: Page frameworks like React use hashed class names that change between builds
- Don’t use !important unnecessarily: This makes overrides difficult and can break page functionality
- Don’t inject styles unconditionally: Always consider the context and user preferences
- Don’t forget about iframes: Content scripts may run in iframe contexts with different styling needs
- Don’t skip the closed shadow root when security matters: Use closed mode for sensitive UIs
Common Pitfalls
Flash of Unstyled Content (FOUC)
FOUC occurs when injected styles load after the page renders initially. Solutions include:
- Using declarative injection in manifest for critical styles
- Implementing a loading state that hides UI until styles apply
- Using CSS
content-visibilityto prevent premature rendering
Style Conflicts
Even with Shadow DOM, conflicts can occur. Always:
- Use specific selectors for your elements
- Prefix your class names if using light DOM
- Test on pages with aggressive CSS frameworks
Memory Leaks
Always clean up when your content script unloads:
// content/lifecycle/cleanup.ts
export function setupLifecycleCleanup(
cleanupFns: Array<() => void>
): void {
// Cleanup on page navigation (for SPAs)
window.addEventListener('unload', () => {
cleanupFns.forEach(fn => fn());
});
// Cleanup when content script is disconnected
if (typeof chrome !== 'undefined' && chrome.runtime?.onDisconnect) {
chrome.runtime.onConnect.addListener((port) => {
port.onDisconnect.addListener(() => {
cleanupFns.forEach(fn => fn());
});
});
}
}
Related Guides
- Content Script Best Practices
- Message Passing Between Components
- Extension UI Design Patterns
- Tab Management Extensions
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.