Content Scripts: Webpage DOM Manipulation and Injection
If you want to build a tool that "converts all prices on a webpage to TWD" or create a feature like Grammarly that adds a check button next to input fields, you must use Content Scripts.
Content Scripts are the only part of an extension that can directly read and modify the DOM of a user's currently browsed webpage.
1. Isolated World
Before writing code, it's crucial to understand a key security mechanism: Isolated World.
When your Content Script is injected into a webpage (e.g., facebook.com), it can read and modify the DOM nodes of facebook.com (e.g., adding a button), but it cannot communicate with the webpage's original JavaScript variables.
For example, if facebook.com defines a global variable window.currentUser, your Content Script cannot access it. Similarly, variables declared in your Content Script won't pollute the original webpage's environment. This ensures high security and stability.
2. Registering Content Scripts in the Manifest
You can use Content Scripts via static declaration or dynamic injection. The most common approach is static declaration:
Add the following configuration to manifest.json:
{
"content_scripts": [
{
"matches": ["https://*.github.com/*", "https://github.com/*"],
"css": ["styles/content.css"],
"js": ["scripts/content.js"],
"run_at": "document_end"
}
]
}
Explanation:
"matches": An array defining which URLs the script will activate on. To avoid performance waste and security risks, specify URLs precisely and avoid using<all_urls>."css"/"js": Paths to the injected stylesheets and scripts."run_at": Determines when the script executes."document_end"means it runs after the webpage DOM is fully parsed, ensuring the elements you target (e.g., buttons or text) already exist on the page.
3. Practical Example: Injecting a Custom Button on GitHub Pages
Suppose we want to add a custom "Quick Copy Repo URL" button to every GitHub repository page.
In scripts/content.js:
// Find GitHub's action header (where "Star" or "Fork" buttons are located)
const actionHeader = document.querySelector('.pagehead-actions');
if (actionHeader) {
// Create our own ListItem
const li = document.createElement('li');
// Create the button
const myBtn = document.createElement('button');
myBtn.className = 'btn btn-sm my-custom-btn'; // Apply our CSS class or reuse GitHub's classes
myBtn.innerText = '⚡️ Copy Repo';
// Bind click event
myBtn.addEventListener('click', () => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl).then(() => {
myBtn.innerText = '✅ Copied!';
setTimeout(() => { myBtn.innerText = '⚡️ Copy Repo'; }, 2000);
});
});
li.appendChild(myBtn);
// Insert the button at the beginning of the list
actionHeader.insertBefore(li, actionHeader.firstChild);
}
With styles/content.css:
.my-custom-btn {
background-color: #8b5cf6 !important;
color: white !important;
border-color: #7c3aed !important;
}
.my-custom-btn:hover {
background-color: #7c3aed !important;
}
That's it! You've successfully altered GitHub's appearance and functionality.
4. Message Passing: Communicating with the Background
Content Scripts have a critical limitation: they can hardly call most chrome.* APIs.
For example, Content Scripts cannot directly make cross-origin API requests (blocked by CORS) or access certain high-permission Chrome core features.
In such cases, we need the help of the Service Worker (background.js)!
Sending a message from Content Script (content.js):
// content.js
const currentTitle = document.title;
// Send a message to background.js
chrome.runtime.sendMessage(
{ action: "saveWebPage", title: currentTitle, url: window.location.href },
(response) => {
// Handle the response from background.js
console.log("Background response:", response.status);
}
);
Receiving and processing in Service Worker (background.js):
// background.js
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "saveWebPage") {
// Extract data
const { title, url } = request;
console.log(`Saving ${title} to database...`);
// Perform cross-origin API requests or database writes (omitted here)
// ...
// Reply to content.js after processing
sendResponse({ status: "Success! Data saved." });
// For async operations, return true to tell Chrome we'll sendResponse later
// return true;
}
});
This is the classic frontend-backend separation model in extensions. Content Scripts act as the "frontend," handling UI rendering and click events, while the Service Worker serves as the "backend," managing data storage and API communication.
In the next chapter, we'll explore how to design your Popup window and user Options page!
Content Script Capabilities
| Capability | Available? | Notes |
|------------|-----------|-------|
| Read DOM | ✅ Yes | Full access to page HTML |
| Modify DOM | ✅ Yes | Add, remove, change elements |
| Access page JS variables | ❌ No | Isolated from page scripts |
| Use chrome.* APIs | ✅ Limited | runtime, storage, i18n |
| Access extension storage | ✅ Yes | chrome.storage.sync/local |
| Make cross-origin requests | ✅ Yes | Same as background |
| Access cookies | ⚠️ Same origin | Only for the host page |
Content Script Injection Methods
| Method | When | Example |
|--------|------|--------|
| Declarative (manifest) | Auto on matched pages | "matches": ["*://*.example.com/*"] |
| Programmatic (tabs API) | On user action | chrome.tabs.executeScript() |
| Dynamic (scripting API) | MV3, at runtime | chrome.scripting.executeScript() |
Manifest Declaration
{
"content_scripts": [{
"matches": ["*://*.camping-site.com/*"],
"css": ["styles.css"],
"js": ["content.js"],
"run_at": "document_end"
}]
}
Programmatic Injection (MV3)
// Inject into a specific tab on user action
chrome.action.onClicked.addListener(async (tab) => {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js']
});
});
// Inject a function
async function injectToTab(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
func: (searchTerm) => {
document.querySelectorAll('.campsite-card').forEach(el => {
if (!el.textContent.toLowerCase().includes(searchTerm.toLowerCase())) {
el.style.display = 'none';
}
});
},
args: ['lake']
});
}
Messaging Between Content Script and Background
// content.js: send message to background
chrome.runtime.sendMessage(
{ action: 'getCampsiteDetails', campsiteId: '123' },
(response) => {
if (response) {
updatePopupWithDetails(response);
}
}
);
// background.js: receive and respond
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'getCampsiteDetails') {
fetchCampsiteDetails(request.campsiteId)
.then(data => sendResponse(data));
return true; // Keep channel open for async
}
});
DOM Manipulation Examples
// Highlight camping prices under $50
function highlightAffordableCampsites() {
const priceEls = document.querySelectorAll('.campsite-price');
priceEls.forEach(el => {
const price = parseFloat(el.textContent.replace('$', ''));
if (price <= 50) {
el.style.backgroundColor = '#dcfce7';
el.style.borderRadius = '4px';
el.style.padding = '2px 6px';
}
});
}
// Add a custom button to each campsite card
function addSaveButton() {
const cards = document.querySelectorAll('.campsite-card');
cards.forEach(card => {
const btn = document.createElement('button');
btn.textContent = '⭐ Save';
btn.className = 'extension-save-btn';
btn.onclick = () => {
const name = card.querySelector('h3').textContent;
chrome.storage.sync.get('savedCampsites', (data) => {
const saved = data.savedCampsites || [];
saved.push({ name, savedAt: Date.now() });
chrome.storage.sync.set({ savedCampsites: saved });
btn.textContent = '✅ Saved';
btn.disabled = true;
});
};
card.appendChild(btn);
});
}
Best Practices
| Practice | Reason |
|----------|--------|
| Use run_at: document_end | DOM is ready but page hasn't finished loading assets |
| Minimize DOM changes | Too many mutations slow down the page |
| Use CSS classes not inline styles | Easier to maintain, less coupling |
| Clean up after yourself | Remove injected elements when extension is disabled |
| Use MutationObserver for dynamic content | New content loaded via AJAX won't trigger content script |
| Communicate via messaging | Don't try to access background directly |
| Add extension CSS prefix | Avoid style conflicts with the host page |
| Handle page navigation (SPA) | History API changes don't re-run content scripts |
Handling Single Page Apps (SPA)
// SPAs use pushState — content script only runs once on initial load
// Solution: listen for URL changes
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
console.log('SPA navigation detected — re-running logic');
onPageChange();
}
}).observe(document, { subtree: true, childList: true });
function onPageChange() {
const path = location.pathname;
if (path.startsWith('/campsites/')) {
loadCampsiteDetails(path.split('/').pop());
} else if (path === '/search') {
enhanceSearchPage();
}
}
Summary
Content scripts are your extension's hands and feet. They read and modify the DOM, communicate with the background service worker via messaging, and enhance the user's browsing experience. Use declarative injection for automatic execution on matched pages, and programmatic injection for triggered actions.
Key takeaways:
- Content scripts have full DOM access but are isolated from page JS
- Use declared matches for automatic injection, scripting API for on-demand
- Messaging:
chrome.runtime.sendMessage()↔onMessagelistener - Use
run_at: document_endfor DOM-ready execution - Add CSS prefixes to avoid style conflicts
- Handle SPA navigation with MutationObserver
- Clean up injected elements when the extension is disabled
- Use MutationObserver for dynamically loaded content
What's Next: Popup & Options Pages
The next chapter covers designing the popup and options pages — the user-facing interface of your extension.