Build a Dark Mode Extension
Build a Chrome MV3 extension that applies dark mode to NextWork pages.
Introduction
30 Second Summary
You know that feeling when you're reading or studying late at night and a blindingly white webpage makes your eyes water? Browser extensions let you take control of your browsing experience. Dark mode is one of the most popular customizations developers build for themselves.
In this project, you will build a Chrome Manifest V3 extension from scratch that transforms NextWork's interface into a comfortable dark theme with a single click.
What You'll Build
When you finish, you'll open any NextWork page, click a toggle in your toolbar, and watch the entire site shift to dark mode instantly. Your preference sticks across tabs and sessions.
By the end of this project, you'll have:
- A dark mode toggle you control from the toolbar that transforms NextWork's light interface into a comfortable dark theme and remembers your choice across browser sessions.
- Hands-on experience reverse-engineering a live website's styling with DevTools and writing targeted CSS overrides that fight existing stylesheets.
- A working understanding of programmatic CSS injection using the chrome.scripting API, popup UIs that communicate with background service workers, and persistent storage with chrome.storage.
- Secret Mission: add a "Dim Images" feature that reduces image brightness in dark mode and restores full brightness on hover.
Are there any prerequisites?
This project assumes you're familiar with the basics of HTML, CSS, and JavaScript. You don't need any prior experience with browser extensions.
Before We Start
You are about to build a browser extension from scratch. Before diving in, let's lock in what you're building and why it matters to you.
Set Up Your Extension Skeleton
Every browser extension starts with a specific file structure that the browser reads to understand what the extension does, what permissions it needs, and how to display it.
Without a valid manifest and folder structure, Orion cannot recognize your project as an extension at all. In this step, you'll create the minimum files needed to get your extension loaded and visible in the browser toolbar.
In this step, get ready to:
- Create the extension folder and write the manifest file.
- Create placeholder files for the popup, background script, and stylesheet.
- Load the extension into Orion and confirm it appears in the toolbar.
Create the extension folder and manifest
The manifest.json file is the heart of any Chrome Manifest V3 extension. It tells the browser the extension's name, version, permissions, and which files to load.
- Switch back to VS Code and open the integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir nextwork-dark
cd nextwork-dark
- Open this folder in VS Code by running:
code .
What does code . do?
The code . command tells VS Code to open the current folder as a workspace. The dot (.) means "this directory." After running it, the Explorer sidebar in VS Code shows your nextwork-dark folder as the project root.
- In the VS Code Explorer sidebar on the left, hover over the NEXTWORK-DARK heading and click the New File icon (a page with a plus sign).
- Type manifest.json and press Enter.
- Paste the following code into manifest.json:
{
"manifest_version": 3,
"name": "NextWork Dark",
"description": "Dark mode toggle for NextWork learning platform",
"version": "1.0",
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Toggle NextWork Dark Mode"
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Toggle dark mode"
}
}
}
What does this manifest do?
- manifest_version: 3 declares this as a Manifest V3 extension, which is Chrome's current extension architecture.
- permissions requests access to the active tab, the scripting API (to inject CSS), and storage (to remember the user's preference).
- background.service_worker registers a service worker that runs in the background and listens for events.
- action defines the popup that appears when you click the extension icon in the toolbar.
- commands registers a keyboard shortcut (Command+Shift+D on macOS, Ctrl+Shift+D on Windows) to toggle dark mode without opening the popup.
- Save manifest.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the file appears in the Explorer sidebar under nextwork-dark.
Don't see the file in the sidebar?
Make sure VS Code's Explorer is showing the nextwork-dark folder. If you see "No folder opened," click File in the top menu, then Open Folder, and select the nextwork-dark folder on your Desktop.
help me open my project folder in VS Code
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
{
"manifest_version": 3,
"name": "NextWork Dark",
"description": "Dark mode toggle for NextWork learning platform",
"version": "1.0",
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Toggle NextWork Dark Mode"
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Toggle dark mode"
}
}
}
Create the popup and placeholder files
Your manifest references four files that don't exist yet: background.js, popup.html, popup.js, and dark-mode.css. You need to create all of them so Orion doesn't throw errors when loading the extension.
- Click the New File icon in the Explorer sidebar again.
- Type popup.html and press Enter.
- Paste this minimal HTML into popup.html:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>NextWork Dark</title></head>
<body>
<h1>NextWork Dark</h1>
<input type="checkbox" id="dark-toggle">
</body>
</html>
What does this popup do?
This is the UI that appears when you click the extension icon in the toolbar. Right now it just shows a heading and a bare checkbox. In a later step, you'll style this into a proper toggle switch and wire it up with JavaScript.
- Save popup.html.
- Create the three remaining placeholder files by running this in the VS Code terminal:
touch background.js popup.js dark-mode.css
- Confirm all five files exist by running:
ls
You should see background.js, dark-mode.css, manifest.json, popup.html, and popup.js listed in the output.
Missing files in the output?
Make sure your terminal is in the nextwork-dark folder. Run pwd to check your current directory. If you're somewhere else, run cd ~/Desktop/nextwork-dark and try the touch command again.
help me find and fix my missing extension files
Load the extension into Orion
Your file structure is complete. Now you'll load the folder into Orion so it recognizes your project as a real extension.
- Press Cmd+Space to open Spotlight search.
- Type Orion and press Enter to open the browser.
- In the menu bar, click Orion, then click Settings.
- Click the Advanced tab.
- Find and enable the checkbox labeled Allow installation of 3rd party Chrome extensions.
- Close the Settings window.
Why enable third-party extensions?
By default, Orion only allows extensions from official web stores. Since you're building a local extension that isn't published anywhere yet, you need to tell Orion to trust extensions loaded from disk.
- In the menu bar, click Tools.
- Hover over Extensions, then click Manage Extensions.
- In the Manage Extensions window, click Install from Disk.
- Navigate to your Desktop, select the nextwork-dark folder, and click Open.
Before you check the result, what do you expect to see in the extension manager if the manifest is valid?
You should see "NextWork Dark" listed in the Manage Extensions window with no error messages. The extension icon also appears in the Orion toolbar at the top of the browser.
Seeing an error or the extension doesn't appear?
- Check that your manifest.json has no trailing commas or syntax errors. JSON does not allow a comma after the last item in an object or array.
- Confirm that background.js and popup.html exist in the same folder as manifest.json. Missing referenced files can cause load failures.
- Make sure you enabled the Allow installation of 3rd party Chrome extensions setting in Orion's Advanced preferences before loading.
help me fix my extension loading error in Orion
Your extension skeleton is loaded and recognized. Next up, you'll write actual dark mode CSS and inject it into NextWork pages so you can see a real visual change.
Inject a Hard-Coded Dark Stylesheet
Your extension skeleton is loaded, but it does nothing visible yet. Before wiring up toggle logic or building a polished popup, you need proof that your CSS can actually change how NextWork looks.
In this step, you will inject a dark mode stylesheet directly into every NextWork page using a static content_scripts declaration. This gives you instant visual feedback on every page load so you can iterate on your CSS without worrying about JavaScript yet.
In this step, get ready to:
- Register a content script that injects your CSS into all NextWork pages.
- Use DevTools to inspect NextWork's actual page structure and identify key selectors.
- Write a complete dark mode stylesheet that transforms the page on load.
Tell Orion to inject your CSS on NextWork pages
A content_scripts entry in your manifest tells the browser to automatically load specified files into pages that match a URL pattern. By setting run_at to document_start, the CSS applies before the page even renders, preventing a flash of light content.
- In VS Code, open manifest.json in your nextwork-dark folder.
- Find the closing brace of the commands block (the } just before the final } of the file).
- Add a comma after that closing brace, then paste the following content_scripts entry below it:
"content_scripts": [
{
"matches": ["https://*.nextwork.org/*", "https://nextwork.org/*"],
"css": ["dark-mode.css"],
"run_at": "document_start"
}
]
What does this code do?
- matches tells Orion which URLs trigger the injection. The two patterns cover both nextwork.org and any subdomain like learn.nextwork.org.
- css lists the stylesheet files to inject. Your dark-mode.css will be applied automatically.
- run_at set to document_start injects the CSS before the page paints, so the user never sees a flash of white.
- Save manifest.json.
- In Orion, click Tools in the menu bar.
- Select Extensions.
- Find NextWork Dark in the list, disable it, then re-enable it to reload the updated manifest.
Seeing an error after re-enabling?
A JSON syntax error usually means a missing or extra comma. Check that there is a comma after the closing } of the commands block, and that the file's final } has no trailing comma before it.
Help me find the JSON formatting issue in my manifest.
✔️ Awesome, I've got everything!
Great. Double check you have saved manifest.json and that the extension re-enabled without errors.
ⓧ I'd like to double check the full code
{
"manifest_version": 3,
"name": "NextWork Dark",
"description": "Dark mode toggle for NextWork learning platform",
"version": "1.0",
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Toggle NextWork Dark Mode"
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Toggle dark mode"
}
},
"content_scripts": [
{
"matches": ["https://*.nextwork.org/*", "https://nextwork.org/*"],
"css": ["dark-mode.css"],
"run_at": "document_start"
}
]
}
Inspect NextWork's page structure with DevTools
Before writing dark mode overrides, you need to know what you are overriding. DevTools lets you see the exact selectors, background colors, and text colors the site uses right now.
- In Orion, navigate to https://nextwork.org.
- Right-click any element on the page and select Inspect Element.
- In the Elements panel, click on the body tag. Look at the Styles pane on the right to see its background-color and color values.
- Click on a navigation bar element, a card component, a heading, a code block, a callout box, a button, and a link. For each one, note the selector (tag name or class attribute) and the key colors applied.
Why inspect manually?
NextWork uses React with hashed class names, so the actual selectors can change between deploys. Attribute selectors like [class*="card"] match any class containing that substring, making your overrides resilient to future changes.
You should now have a mental map of the page structure: backgrounds are light, text is dark, cards have borders, and interactive elements use accent colors. Time to invert all of that.
Write the dark mode stylesheet
You will build dark-mode.css in chunks, verifying after each group of changes. Every rule uses !important because your extension CSS must override the site's existing styles.
- Open dark-mode.css in VS Code. Delete the placeholder comment and paste the following base overrides:
/* NextWork Dark Mode - Base overrides */
body,
html {
background-color: #0f1115 !important;
color: #e8e8ea !important;
}
/* Navigation and header */
header,
nav,
[class*="nav"],
[class*="header"],
[class*="toolbar"] {
background-color: #161820 !important;
border-color: #2a2f3a !important;
color: #e8e8ea !important;
}
/* Main content area */
main,
article,
section,
[class*="content"],
[class*="container"],
[class*="wrapper"] {
background-color: #0f1115 !important;
color: #e8e8ea !important;
}
What does this code do?
- The body and html rule sets the entire page to a dark slate background (#0f1115) with light text (#e8e8ea). This is your foundation.
- The navigation selectors use attribute matching ([class*="nav"]) to catch any element whose class contains "nav", regardless of hashed suffixes.
- The main content selectors ensure the page body and wrappers all share the same dark background.
- Save dark-mode.css.
- In Orion, disable and re-enable the extension under Tools then Extensions to pick up the CSS changes.
- Refresh nextwork.org. The page background should now be dark and body text should appear light.
Page still looks white?
Make sure you disabled and re-enabled the extension after saving the CSS. Orion needs to reload the extension to pick up new file contents referenced in content_scripts.
Also confirm the URL matches your pattern. The overrides only apply on nextwork.org and its subdomains.
Help me troubleshoot why my CSS is not being injected.
- Add the following below your existing CSS to style cards, headings, and body text:
/* Cards and surfaces */
[class*="card"],
[class*="panel"],
[class*="surface"],
[class*="box"],
[class*="modal"],
[class*="dialog"],
[class*="dropdown"] {
background-color: #1c1f26 !important;
border-color: #2a2f3a !important;
color: #e8e8ea !important;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
color: #f3f4f6 !important;
}
/* Paragraphs and body text */
p, li, span, td, th, label, dt, dd {
color: #d1d5db !important;
}
What does this code do?
- Card selectors give surfaces a slightly lighter shade (#1c1f26) than the page background, creating depth without brightness.
- Headings get a near-white (#f3f4f6) to stand out from body text.
- Body text uses a softer gray (#d1d5db) that is easy to read without being harsh.
- Next, add link and button styles below the body text rule:
/* Links */
a {
color: #60a5fa !important;
}
a:hover {
color: #93c5fd !important;
}
/* Buttons - primary */
button,
[class*="btn"],
[role="button"] {
background-color: #2563eb !important;
color: #ffffff !important;
border-color: #3b82f6 !important;
}
button:hover,
[class*="btn"]:hover,
[role="button"]:hover {
background-color: #1d4ed8 !important;
}
What does this code do?
Links use a soft blue (#60a5fa) that provides good contrast on dark backgrounds without straining your eyes. Buttons get a deeper blue (#2563eb) that darkens on hover for a tactile feel.
- Save dark-mode.css, reload the extension, and refresh nextwork.org.
- Cards should now have a visible dark surface, headings and body text are clearly readable, and links appear as soft blue.
- Add code block and callout styles below your existing CSS:
/* Code blocks */
pre,
code,
[class*="code"] {
background-color: #0d0f12 !important;
color: #a5f3fc !important;
border-color: #2a2f3a !important;
}
/* Callouts and alerts */
[class*="callout"],
[class*="alert"],
[class*="notice"],
[class*="info"],
[class*="warning"],
[class*="tip"] {
background-color: #1a1f2e !important;
border-color: #334155 !important;
color: #d1d5db !important;
}
What does this code do?
- Code blocks get a near-black background (#0d0f12) with cyan text (#a5f3fc) for a terminal-like feel.
- Callouts use a tinted dark (#1a1f2e) that subtly distinguishes them from regular content without being too bright.
- Add form input, sidebar, progress bar, and divider overrides:
/* Input fields and forms */
input,
textarea,
select {
background-color: #1c1f26 !important;
color: #e8e8ea !important;
border-color: #3a3f4a !important;
}
input:focus,
textarea:focus,
select:focus {
border-color: #60a5fa !important;
outline-color: #60a5fa !important;
}
/* Table of contents / sidebar */
[class*="sidebar"],
[class*="toc"],
[class*="menu"] {
background-color: #161820 !important;
border-color: #2a2f3a !important;
}
/* Progress bars */
[class*="progress"] {
background-color: #2a2f3a !important;
}
/* Dividers and borders */
hr,
[class*="divider"],
[class*="separator"] {
border-color: #2a2f3a !important;
background-color: #2a2f3a !important;
}
What does this code do?
Form inputs get the same surface color as cards so they blend in. The focus state uses blue (#60a5fa) to clearly show which field is active.
Sidebars match the navigation shade, and dividers use a subtle dark gray instead of disappearing entirely.
- Save dark-mode.css, reload the extension, and refresh the page.
- Code blocks should now have a distinct near-black background. Callouts, sidebars, and form inputs all render with dark backgrounds and visible borders.
- Finally, add the polish layer for images, scrollbars, and smooth transitions:
/* Images - subtle containment */
img {
border-radius: 4px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0f1115;
}
::-webkit-scrollbar-thumb {
background: #3a3f4a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4f5a;
}
/* Smooth transitions for toggling */
body *:not(img):not(video):not(canvas) {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
What does this code do?
- Images get a subtle border-radius so they sit cleanly against the dark background.
- Scrollbar styling replaces the default bright scrollbar with a dark track and muted thumb.
- The transition rule ensures that when dark mode is later toggled on and off, the color shift feels smooth rather than jarring. It excludes images, videos, and canvases to avoid filter flicker.
- Save dark-mode.css.
- Reload the extension one more time (disable and re-enable under Tools then Extensions).
Before you refresh the page, what do you expect to see? Think about what the full dark mode should look like across the entire page.
- Refresh nextwork.org.
The entire page should render with a dark background and light text immediately on load. Navigate through different pages to confirm navigation, cards, buttons, code blocks, and callouts all look cohesive.
Some elements still look white?
If specific components still show light backgrounds, right-click them and select Inspect Element to identify their class names. Your attribute selectors ([class*="..."]) may not match the exact substring used.
If the entire page is still white, confirm the extension shows no errors in the extensions list and that dark-mode.css is saved in the same folder as manifest.json.
Help me figure out which selectors are missing or not matching.
✔️ Awesome, I've got everything!
Great. Confirm you have saved dark-mode.css and that the page looks consistently dark across all sections.
ⓧ I'd like to double check the full code
Your dark-mode.css file should contain these sections in order. Scroll through your file and confirm each group is present:
- Base overrides (body, html, navigation, main content area).
- Cards and surfaces.
- Headings and body text.
- Links and link hover states.
- Buttons and button hover states.
- Code blocks.
- Callouts and alerts.
- Input fields and focus states.
- Sidebar, progress bars, and dividers.
- Images, scrollbar, and smooth transitions.
Your dark theme is live and working. Every time you open a NextWork page, the dark stylesheet injects automatically. There is one problem though: you have no off switch. In the next step, you will replace this hard-coded injection with programmatic control so you can toggle dark mode on and off without uninstalling the extension.
Build the Toggle with Programmatic Injection
Your dark mode works, but there is a problem. Try turning it off. You cannot. The content_scripts approach injects CSS on every page load with no way to undo it.
In this step, you will replace that static injection with programmatic control. Instead of the browser injecting CSS automatically, your extension will use chrome.scripting.insertCSS and chrome.scripting.removeCSS to add or remove the stylesheet on demand.
In this step, get ready to:
- Replace static CSS injection with programmatic chrome.scripting calls.
- Build a styled popup with a toggle switch that controls dark mode.
- Persist the dark mode preference so it survives page reloads.
Remove static injection and build the background script
The content_scripts entry in your manifest forces CSS onto every matching page with no off switch. Removing it gives your background script full control over when the stylesheet appears.
- In VS Code, open manifest.json.
- Find the content_scripts block at the bottom of the file:
"content_scripts": [
{
"matches": ["https://*.nextwork.org/*", "https://nextwork.org/*"],
"css": ["dark-mode.css"],
"run_at": "document_start"
}
]
- Delete the entire content_scripts block (including the comma on the line above it). Your manifest should now end with the closing brace of the commands object:
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Toggle dark mode"
}
}
}
- Save manifest.json.
- In Orion, go to Tools in the menu bar.
- Hover over Extensions, then click Manage Extensions.
- Disable your NextWork Dark extension by unchecking it, then re-enable it by checking it again. This forces Orion to reload the updated manifest.
- Navigate to nextwork.org.
The page now loads in its original light theme. Dark mode is gone because there is nothing injecting the CSS anymore.
Why did dark mode disappear?
The content_scripts entry was the only thing injecting your CSS. With it removed, the browser no longer applies dark-mode.css to any page. Your background script will take over this job next.
Now you will write the background script that can inject and remove your stylesheet on command. Add the core helper functions to background.js by replacing its placeholder content with this code:
const NEXTWORK_PATTERNS = [
"https://*.nextwork.org/*",
"https://nextwork.org/*"
];
function isNextWorkUrl(url) {
if (!url) return false;
return url.includes("nextwork.org");
}
async function applyDarkMode(tabId) {
try {
await chrome.scripting.insertCSS({
files: ["dark-mode.css"],
target: { tabId: tabId }
});
} catch (error) {
console.error("Failed to insert CSS:", error);
}
}
async function removeDarkMode(tabId) {
try {
await chrome.scripting.removeCSS({
files: ["dark-mode.css"],
target: { tabId: tabId }
});
} catch (error) {
console.error("Failed to remove CSS:", error);
}
}
What does this code do?
NEXTWORK_PATTERNS stores the URL patterns for reference. isNextWorkUrl() is a quick check that returns true only for NextWork pages.
applyDarkMode() calls chrome.scripting.insertCSS to inject your stylesheet into a specific tab. removeDarkMode() calls chrome.scripting.removeCSS to pull it back out. Both use try/catch so a closed tab does not crash your extension.
- Below the removeDarkMode function, add the message listener and toggle handler:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "toggle") {
handleToggle(message.enabled);
sendResponse({ success: true });
}
return true;
});
async function handleToggle(enabled) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab && isNextWorkUrl(tab.url)) {
if (enabled) {
await applyDarkMode(tab.id);
} else {
await removeDarkMode(tab.id);
}
}
}
What does this code do?
chrome.runtime.onMessage.addListener listens for messages sent from your popup. When the popup sends a message with action: "toggle", it calls handleToggle().
handleToggle() finds the active tab, confirms it is a NextWork page, and either injects or removes the CSS depending on the enabled flag. The return true keeps the message channel open for the async response.
- Save background.js.
- In Orion, go to Tools > Extensions > Manage Extensions.
- Disable and re-enable NextWork Dark to reload the extension.
- Confirm the extension still appears in the toolbar with no error badge.
Extension showing an error?
Check for syntax errors in background.js. A missing comma or unmatched brace will prevent the service worker from loading. You can view console errors via Tools > Extensions > Console (from the extension's options).
Also confirm the trailing comma before the removed content_scripts block is gone from manifest.json. JSON does not allow trailing commas.
help me debug my background.js service worker error
Build the popup UI and connect it to the background
Your background script is ready to receive toggle messages. Now you need a popup interface that sends them. The popup will display a styled toggle switch and update chrome.storage.local so the preference persists.
- Open popup.html in VS Code and replace its entire contents with this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 260px; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e8e8ea; }
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.header h1 { font-size: 15px; font-weight: 600; color: #ffffff; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-top: 1px solid #2a2f3a; }
.toggle-row label { font-size: 13px; color: #b0b0b8; }
.switch { position: relative; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #3a3a4a; border-radius: 24px; transition: background-color 0.3s ease; }
.slider::before { content: ""; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: #ffffff; border-radius: 50%; transition: transform 0.3s ease; }
input:checked + .slider { background-color: #60a5fa; }
input:checked + .slider::before { transform: translateX(20px); }
.status { margin-top: 12px; font-size: 11px; color: #6b7280; text-align: center; }
</style>
</head>
<body>
<div class="header"><h1>NextWork Dark</h1></div>
<div class="toggle-row">
<label for="dark-toggle">Dark Mode</label>
<div class="switch">
<input type="checkbox" id="dark-toggle">
<span class="slider"></span>
</div>
</div>
<p class="status" id="status">Inactive</p>
<script src="popup.js"></script>
</body>
</html>
What does this code do?
The popup is a self-contained HTML page that appears when you click your extension icon. The <style> block gives it a dark, compact look. The toggle uses a hidden checkbox with a .slider span styled to look like a switch. When checked, the slider turns blue and the knob slides right.
The #status paragraph shows whether dark mode is currently active. The popup.js script (loaded at the bottom) will wire up the toggle behavior.
- Save popup.html.
- Now open popup.js and replace its placeholder content with this code:
// Cache DOM references
const toggle = document.getElementById("dark-toggle");
const status = document.getElementById("status");
// Load saved preference when popup opens
async function init() {
const { darkMode } = await chrome.storage.local.get("darkMode");
toggle.checked = !!darkMode;
updateStatus(!!darkMode);
}
// Update the status text below the toggle
function updateStatus(enabled) {
status.textContent = enabled ? "Dark mode active" : "Inactive";
}
// Listen for toggle changes and notify background
toggle.addEventListener("change", async () => {
const enabled = toggle.checked;
await chrome.storage.local.set({ darkMode: enabled });
updateStatus(enabled);
chrome.runtime.sendMessage({ action: "toggle", enabled: enabled });
});
init();
What does this code do?
init() runs when the popup opens. It reads the saved darkMode value from chrome.storage.local and sets the checkbox to match. This way the toggle always reflects the current state.
The change listener fires when the user flips the switch. It saves the new preference to storage, updates the status text, then sends a message to background.js so it can inject or remove the CSS on the active tab immediately.
- Save popup.js.
- In Orion, disable and re-enable the extension in Manage Extensions to reload your changes.
- Navigate to nextwork.org (the page should be in light mode).
- Click the NextWork Dark extension icon in the toolbar.
You should see a small, dark popup with a heading and a toggle switch. The status reads "Inactive".
- Flip the toggle ON.
The page instantly shifts to dark mode. The status text updates to "Dark mode active".
- Flip the toggle OFF.
The page returns to its light theme. You now have a proper on/off switch.
Toggle not changing the page?
Make sure you are on a nextwork.org page. The isNextWorkUrl() check in background.js skips injection on other domains.
If the popup appears but nothing happens, check the extension console (Tools > Extensions > Console) for errors in the background script.
help me debug why my dark mode toggle is not applying CSS to the page
Persist dark mode across page navigations
The toggle works on the current page, but try clicking a link on NextWork. The new page loads in light mode because only the tab you manually toggled received the CSS. You need the extension to re-inject automatically whenever a NextWork page finishes loading.
- In background.js, find the chrome.runtime.onMessage.addListener line.
- Add this listener directly above it:
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status === "complete" && isNextWorkUrl(tab.url)) {
const { darkMode } = await chrome.storage.local.get("darkMode");
if (darkMode) {
await applyDarkMode(tabId);
}
}
});
What does this code do?
chrome.tabs.onUpdated fires every time a tab changes state. By checking changeInfo.status === "complete", you only act once the page has fully loaded. If the URL is a NextWork page and darkMode is true in storage, it re-injects the stylesheet automatically.
This means the learner's preference follows them across every NextWork page they visit without needing to click the toggle again.
- Save background.js.
- Disable and re-enable the extension in Manage Extensions.
- Navigate to nextwork.org and click the extension icon.
- Flip the toggle ON so dark mode activates.
Before you test persistence, what do you think will happen when you reload this page? Will it stay dark or revert to light?
- Press Cmd+R to reload the page.
The page reloads and dark mode reappears automatically. Your onUpdated listener detected the page load, checked storage, and re-injected the CSS without any manual click.
Page reverting to light on reload?
Make sure the chrome.tabs.onUpdated listener is placed outside any other function. It should be at the top level of background.js, not nested inside handleToggle.
Also confirm you disabled and re-enabled the extension after saving. Orion does not automatically pick up changes to the background service worker.
help me fix my onUpdated listener that should re-inject CSS on page load
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files: manifest.json, background.js, popup.html, and popup.js.
ⓧ I'd like to double check the full code
Your background.js file has four sections. Scroll through and confirm each is present in this order:
- NEXTWORK_PATTERNS constant and isNextWorkUrl() helper.
- applyDarkMode() and removeDarkMode() functions.
- chrome.tabs.onUpdated.addListener block.
- chrome.runtime.onMessage.addListener block and handleToggle() function.
Here is the full manifest.json:
{
"manifest_version": 3,
"name": "NextWork Dark",
"description": "Dark mode toggle for NextWork learning platform",
"version": "1.0",
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Toggle NextWork Dark Mode"
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Toggle dark mode"
}
}
}
Here is the full popup.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 260px; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e8e8ea; }
.header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.header h1 { font-size: 15px; font-weight: 600; color: #ffffff; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-top: 1px solid #2a2f3a; }
.toggle-row label { font-size: 13px; color: #b0b0b8; }
.switch { position: relative; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #3a3a4a; border-radius: 24px; transition: background-color 0.3s ease; }
.slider::before { content: ""; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: #ffffff; border-radius: 50%; transition: transform 0.3s ease; }
input:checked + .slider { background-color: #60a5fa; }
input:checked + .slider::before { transform: translateX(20px); }
.status { margin-top: 12px; font-size: 11px; color: #6b7280; text-align: center; }
</style>
</head>
<body>
<div class="header"><h1>NextWork Dark</h1></div>
<div class="toggle-row">
<label for="dark-toggle">Dark Mode</label>
<div class="switch">
<input type="checkbox" id="dark-toggle">
<span class="slider"></span>
</div>
</div>
<p class="status" id="status">Inactive</p>
<script src="popup.js"></script>
</body>
</html>
Here is the full popup.js:
const toggle = document.getElementById("dark-toggle");
const status = document.getElementById("status");
async function init() {
const { darkMode } = await chrome.storage.local.get("darkMode");
toggle.checked = !!darkMode;
updateStatus(!!darkMode);
}
function updateStatus(enabled) {
status.textContent = enabled ? "Dark mode active" : "Inactive";
}
toggle.addEventListener("change", async () => {
const enabled = toggle.checked;
await chrome.storage.local.set({ darkMode: enabled });
updateStatus(enabled);
chrome.runtime.sendMessage({ action: "toggle", enabled: enabled });
});
init();
You now have a fully functional toggle. Dark mode turns on and off with a click, and your preference persists across page loads and navigations. Next up, you will polish the dark theme to handle specific NextWork components that still look broken.
Refine the Dark Theme for NextWork Components
Your toggle works and broad dark styles apply, but how do you know your CSS is actually hitting every component on a real NextWork page?
Callout boxes, code blocks, progress bars, the table of contents sidebar, and image containers each have their own class names. In this step, you will use DevTools to inspect the live page and confirm your selectors are reaching every component without leaving jarring white patches or invisible text.
In this step, get ready to:
- Use Inspect Element to identify and verify component-specific selectors on a live NextWork page.
- Confirm edge-case handling for images, input fields, and SVG icons.
- Verify smooth transitions when toggling dark mode on and off.
Inspect component-specific selectors with DevTools
Your dark-mode.css already contains attribute selectors like [class*="callout"] and [class*="code"]. These target any element whose class name contains that substring. Now you need to confirm they actually match NextWork's DOM.
- In Orion, navigate to a NextWork project page that has multiple content types (callouts, code blocks, a table of contents).
- Make sure dark mode is toggled ON using your extension popup.
- Right-click on a callout box and select Inspect Element.
In the Elements panel, look at the class names on the callout element. You should see something containing "callout", "alert", or "info" in the class string.
- In the Styles pane on the right, scroll through the applied styles. Look for your dark mode overrides showing background-color: #1a1f2e !important from your injected stylesheet.
- Right-click on a code block element and select Inspect Element. Confirm you see background-color: #0d0f12 !important applied.
- Inspect the sidebar or table of contents. Confirm it picks up the #161820 background from your [class*="sidebar"] selector.
Why attribute selectors instead of exact class names?
NextWork uses React, which can append hashed suffixes to class names (like callout_a3x2f). A selector like [class*="callout"] matches any class containing that substring, so your overrides survive class name changes between deploys.
Your callouts should display with a tinted dark background (#1a1f2e) and a slate border (#334155). Code blocks should appear near-black (#0d0f12) with light cyan syntax text (#a5f3fc).
Selectors not matching?
If you do not see your dark overrides applied in the Styles pane, check that the extension is active (the popup should say "Dark mode active"). Also confirm you are on a nextwork.org page, since your background script only injects CSS on matching URLs.
Help me figure out why my dark mode CSS isn't being applied to NextWork components.
Verify edge-case handling for images, forms, and icons
Broad color overrides can break specific elements. Images with transparent backgrounds look wrong against dark surfaces. Input fields become invisible if their text matches the background. SVG icons that use currentColor should adapt automatically, but you need to verify.
- Find an image on a NextWork page. Right-click it and select Inspect Element.
- In the Styles pane, confirm that border-radius: 4px is applied from your CSS. This gives images subtle containment against the dark background.
Your CSS already handles this with the img rule in dark-mode.css:
/* Images - subtle containment */
img {
border-radius: 4px;
}
- Now find any input field or search box on the page. Click into it and type a few characters.
- Confirm the text is visible (light text on dark background). Click away, then click back to confirm the blue focus ring (#60a5fa) appears.
Your input styles handle this with separate rules for default state and focus state:
/* Input fields and forms */
input,
textarea,
select {
background-color: #1c1f26 !important;
color: #e8e8ea !important;
border-color: #3a3f4a !important;
}
input:focus,
textarea:focus,
select:focus {
border-color: #60a5fa !important;
outline-color: #60a5fa !important;
}
What about SVG icons?
SVG icons that use currentColor for their fill or stroke automatically inherit the text color from their parent element. Since your CSS overrides text color to light values, these icons adapt without needing their own rules.
If you spot an icon that stays dark (invisible against the background), it likely uses a hard-coded fill color. You can add a specific override for that selector later.
- Look for any SVG icons on the page (navigation arrows, checkmarks, info icons). Confirm they appear in a light color that is visible against the dark background.
Seeing invisible text in an input?
If text disappears when you type into a field, inspect the element and check whether a more specific inline style is overriding your CSS. Inline styles beat !important in some cases. You may need a more specific selector targeting that particular input's wrapper class.
Help me debug why text is invisible in an input field with my dark mode extension.
Confirm smooth transitions when toggling
A polished dark mode does not just snap colors on and off. It eases between states so the toggle feels intentional. Your CSS applies a transition to all elements except media:
/* Smooth transitions for toggling */
body *:not(img):not(video):not(canvas) {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
Why exclude images, video, and canvas?
Transition rules on media elements can cause flickering or performance issues. Images do not change color, so transitioning them is wasteful. The :not() pseudo-class excludes them cleanly.
- Click your extension icon in the Orion toolbar to open the popup.
- Toggle dark mode OFF. Watch the page colors.
Before you toggle it back ON, what do you expect to see happen to the page background and text colors? Will it snap instantly or fade?
- Toggle dark mode back ON.
You should see backgrounds, text, and borders smoothly transition over 0.2 seconds rather than snapping abruptly. The transition only applies when removing CSS (toggling OFF). When inserting CSS, the styles apply immediately since the transition rule itself is part of the injected sheet.
Why does toggle-off animate but toggle-on does not?
When you inject the CSS, the transition rule arrives at the same time as the color changes, so there is no "before" state to transition from. When you remove the CSS, the browser already has the transition rule applied, so it animates back to the original colors before the rule disappears.
Full walkthrough verification
Time to stress-test your dark theme across a complete NextWork project.
- Make sure dark mode is toggled ON.
- Navigate to any full NextWork project page.
- Scroll through the intro, step content, code blocks, callouts, and any images.
- Check for any jarring white patches or text that disappears against its background.
All components should render readably with no white patches or invisible text. Callouts appear as slightly tinted dark boxes. Code blocks are near-black with light syntax text. The navigation sidebar is a darker shade than the main content area.
Seeing a white patch on a specific component?
Right-click the white element and select Inspect Element. Check its class name. If none of your attribute selectors match it, you can add a new rule to dark-mode.css targeting that specific class. Remember to reload the extension after saving changes.
Help me fix a white patch I'm seeing in dark mode on a NextWork page.
✔️ Awesome, I've got everything!
Great. Double check you have saved dark-mode.css if you made any tweaks during inspection.
ⓧ I'd like to double check the full code
Your dark-mode.css is too long to display in one block. Scroll up through the chunks you built in Step 2 and confirm each section is present in this order:
- Base overrides (body/html, navigation, main content, cards).
- Typography (headings, paragraphs, links).
- Interactive elements (buttons, code blocks, callouts).
- Forms (input, textarea, select with focus states).
- Layout (sidebar/toc, progress, dividers).
- Media (img border-radius, scrollbar styling).
- Transitions (the body * rule excluding img/video/canvas).
Your dark theme now handles every component you will encounter on NextWork. Next up, you will tackle the Secret Mission and add an image dimming feature that makes browsing even more comfortable at night.
Secret mission
Add a "Dim Images" Feature
In dark mode, bright images create harsh contrast that strains your eyes. Can you add a dim-on-idle, bright-on-hover pattern that reduces image brightness and restores it when the user hovers? This is a professional dark mode polish that most extensions skip.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The nextwork-dark project folder on your Desktop (contains manifest.json, background.js, popup.html, popup.js, dark-mode.css, dim-images.css).
- The NextWork Dark extension loaded in Orion Browser.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep using your dark mode extension on NextWork.
- Your extension stays loaded in Orion and continues to work on NextWork pages.
- Your project files remain in the nextwork-dark folder on your Desktop.
- Your dark mode and dim images preferences persist in chrome.storage.local.
Pause - I'll come back to this later
Disable the extension in Orion so it stops running, but keep your files for later.
- Open Orion's extension manager.
- Find NextWork Dark in the list and toggle it off (disable it).
- Your project files stay on disk. When you want to resume, re-enable the extension from the same manager.
Delete - I don't want to use this again
Remove the extension from Orion and delete all project files from your machine.
- Open Orion's extension manager.
- Find NextWork Dark and click the remove or uninstall button. This also clears the chrome.storage.local data the extension saved.
- Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/nextwork-dark
On Windows, run this instead:
rmdir /s /q "$HOME\Desktop\nextwork-dark"
- Confirm the folder is gone by running ls ~/Desktop (macOS) or dir $HOME\Desktop (Windows). You should no longer see nextwork-dark in the listing.
Nice Work!
Nice Work!
You just built a fully functional browser extension from scratch and loaded it into Orion. Your NextWork Dark extension transforms any NextWork page into a comfortable dark theme with a single click.
You've learned how to:
- Build a complete Chrome Manifest V3 extension from scratch, including a manifest, background service worker, popup UI, and content stylesheets, and load it into Orion as a local development build.
- Implement programmatic CSS injection with chrome.scripting.insertCSS and chrome.scripting.removeCSS to toggle styles on a live page without reloading.
- Persist user preferences with chrome.storage.local so they survive across sessions, tabs, and page navigations.
- Secret Mission: implemented image dimming with a hover-reveal pattern for comfortable dark mode browsing.
Ready to quiz yourself?