Build a Dark Mode Extension
Build a Chrome MV3 extension that themes NextWork with no flash of white.
Introduction
30 Second Summary
You know that moment when you open a bright website late at night and get blasted by a white screen? Browser extensions let you take control and build exactly the dark mode you want.
In this project, you will build a Manifest V3 dark mode browser extension for the NextWork learning platform from scratch. No build tools, no dependencies. Just HTML, CSS, and JavaScript loaded directly into your browser.
What You'll Build
You click the toolbar icon, flip a toggle, and every NextWork page goes dark instantly. Navigate to another page and it stays dark with no flash of white, no missed surfaces, and preferences that follow you across tabs and browser restarts.
By the end of this project, you'll have:
- A warm dark theme matched to NextWork's own colour palette that applies instantly with no flash of white on page load.
- A popup toggle that persists your preference across tabs, navigations, and browser restarts.
- A surface-measurement system that catches elements CSS selectors cannot reach by reading computed styles and writing inline overrides.
- Secret Mission: A keyboard shortcut to toggle dark mode without opening the popup.
Are there any prerequisites?
You should be comfortable with basic HTML, CSS, and JavaScript. You also need a Chromium-based browser installed (Chrome, Edge, Brave, Arc, or Orion).
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
Browser extensions need a specific set of files arranged in a folder before the browser can recognize them. Without the right structure, the browser has nothing to load.
In this step, you will create that folder structure, write the configuration file that identifies your extension to the browser, and load it so you have a live extension to iterate against.
In this step, get ready to:
- Create the nextwork-dark project folder with six files.
- Write the manifest that tells the browser what the extension does.
- Load the extension and verify it appears in the toolbar.
Create the project folder and files
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- 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
- Create the six empty files your extension needs:
macOS
touch manifest.json theme.css content.js popup.html popup.css popup.js
Windows
New-Item -ItemType File -Path manifest.json, theme.css, content.js, popup.html, popup.css, popup.js
- Confirm the files exist by running:
ls
You should see all six files listed: manifest.json, theme.css, content.js, popup.html, popup.css, and popup.js.
Not seeing all six files?
Make sure you ran the create command from inside the nextwork-dark folder. Run pwd to check your current location.
If you see a typo in a filename, delete it and re-run the create command for just that file.
help me troubleshoot missing files in my project folder
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or your editor's name) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the nextwork-dark folder on your Desktop, then click Open.
Write the manifest
Every browser extension needs a manifest.json file. It tells the browser the extension's name, what permissions it needs, and which files to load.
- Open manifest.json in your editor and paste the following:
{
"manifest_version": 3,
"name": "NextWork Dark",
"description": "Dark mode for the NextWork learning platform.",
"version": "1.0",
"permissions": ["storage"],
"host_permissions": [
"https://nextwork.ai/*",
"https://*.nextwork.ai/*",
"https://nextwork.org/*",
"https://*.nextwork.org/*"
],
What does this code do?
- manifest_version: 3 declares this as a Manifest V3 extension, the current Chrome extension format.
- permissions requests the storage API so the extension can remember the user's dark mode preference.
- host_permissions lists which websites the extension is allowed to run on. The patterns cover both nextwork.ai and nextwork.org including all subdomains.
- Add the following directly below, still inside the same top-level { object:
"content_scripts": [
{
"matches": [
"https://nextwork.ai/*",
"https://*.nextwork.ai/*",
"https://nextwork.org/*",
"https://*.nextwork.org/*"
],
"css": ["theme.css"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
}
],
"action": {
"default_popup": "popup.html",
"default_title": "NextWork Dark"
}
}
What does this code do?
- content_scripts tells the browser to inject theme.css and content.js into every matching page.
- run_at: "document_start" means the CSS and script load before the page renders anything. This is critical for preventing a flash of white.
- all_frames: true injects into iframes on the page too, not just the top-level document.
- action registers the toolbar icon and points its popup to popup.html.
- Save manifest.json.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your manifest.json is over 30 lines, so scroll up and confirm both chunks are present in order:
- Chunk 1: opens with { and ends at the host_permissions closing bracket with a trailing comma.
- Chunk 2: starts with "content_scripts" and ends with the final } closing brace.
- Now open popup.html and paste this placeholder page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NextWork Dark</title>
</head>
<body>
<p>NextWork Dark</p>
</body>
</html>
Why a placeholder popup?
The manifest points to popup.html as the popup file. If that file is empty, the browser shows nothing when you click the icon. This placeholder gives you a visible confirmation that the popup is wired up correctly.
- Save popup.html.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your popup.html should look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NextWork Dark</title>
</head>
<body>
<p>NextWork Dark</p>
</body>
</html>
Load the extension in the browser
Your files are ready. Now you need to tell the browser to load the folder as an unpacked extension so you can test it live.
Chrome, Edge, Brave, or Arc
- Open a new tab and navigate to chrome://extensions.
- Enable the Developer mode toggle in the top-right corner.
- Click Load unpacked.
- Select the nextwork-dark folder on your Desktop, then click Select Folder (Windows) or Open (macOS).
Orion
- Open Settings and navigate to Advanced.
- Enable Allow installation of 3rd party Chrome extensions.
- Go to Tools in the menu bar.
- Click Extensions, then Manage Extensions.
- Click Install from Disk and select the nextwork-dark folder on your Desktop.
Orion caches the manifest
Orion reads manifest.json only at install time. If you change the manifest later, you must remove and re-add the extension. Disable/re-enable is not enough.
You should see the extension card appear on the extensions page with the name "NextWork Dark".
Before you click the icon, what do you think you will see when the popup opens?
- Find the extension icon in your browser toolbar and click it.
A small popup appears showing the text "NextWork Dark". That confirms the manifest is wired correctly and the browser loaded your extension.
Don't see the extension icon?
- Check the extensions page for error messages. A red error banner usually means a typo in manifest.json. Look for missing commas or mismatched brackets.
- If the icon loaded but the popup is blank, make sure popup.html is saved with content (not empty).
- On Chrome, some extensions are hidden behind the puzzle-piece icon in the toolbar. Click it and pin "NextWork Dark".
help me troubleshoot loading my unpacked extension
Your extension skeleton is loaded and responding. Next up, you will write the dark theme CSS and see it transform a live NextWork page.
Build an Always-On Dark Theme
Your extension is loaded and the browser recognises it, but it does nothing visible yet. The whole point of this project is a dark theme, so let's build one.
In this step, you'll write a stylesheet that turns NextWork pages dark the moment they load. The theme will be unconditional for now. You'll notice two deliberate shortcomings: you can't turn it off, and some surfaces stay light. Those are the problems you solve in later steps.
In this step, get ready to:
- Define a warm dark colour palette using CSS custom properties.
- Write CSS rules that restyle backgrounds, text, links, inputs, and code blocks.
- Reload the extension and see NextWork go dark.
Define the colour palette and base element rules
Every dark theme needs a consistent palette. Instead of scattering hex values across dozens of rules, you'll define them once as CSS custom properties and reference them everywhere.
- Open theme.css in your editor. The file is currently empty.
- Add the colour palette and base element rules by pasting this code:
/* Ungated rules — apply unconditionally */
:root {
--nwd-canvas: #1a1817;
--nwd-surface: #272320;
--nwd-text: #ede8e3;
--nwd-body: #cfc7c0;
--nwd-muted: #9c928a;
--nwd-link: #7fb3f2;
}
html, body {
background-color: var(--nwd-canvas) !important;
color: var(--nwd-body) !important;
}
h1, h2, h3, h4, h5, h6 {
color: var(--nwd-text) !important;
}
a {
color: var(--nwd-link) !important;
}
What does this code do?
The :root block defines six colour variables. --nwd-canvas is the darkest background (page canvas), --nwd-surface is a slightly lighter shade for cards and panels, and --nwd-text / --nwd-body are two text brightnesses (headings vs body).
The !important flag on every declaration forces your styles to override the site's own stylesheets. Without it, the page's existing rules would win because they have more specific selectors.
Add rules for inputs, code blocks, and catch-all selectors
The base rules cover the page canvas and text, but NextWork also has form fields, code blocks, and components that use Tailwind utility classes like bg-white. These need their own rules.
- Add the following rules below the code you just pasted in theme.css:
input, textarea, select {
background-color: var(--nwd-surface) !important;
color: var(--nwd-text) !important;
}
pre, code, [class*="code-block"] {
background-color: #141211 !important;
color: var(--nwd-body) !important;
}
[class*="bg-white"],
[class*="bg-secondary"] {
background-color: var(--nwd-surface) !important;
}
What does this code do?
- input, textarea, select darkens all form fields so they don't appear as bright white boxes.
- pre, code, [class*="code-block"] gives code snippets an even darker background (#141211) to visually separate them from surrounding content.
- [class*="bg-white"] is a catch-all selector. It matches any element whose class name contains the string bg-white. This catches Tailwind utility classes without needing to know the exact class.
- Save theme.css.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file, then move on to reload the extension.
ⓧ I'd like to double check the full code
Scroll up and confirm your theme.css has both chunks in order:
- Chunk 1: The :root custom properties block, followed by html, body, headings, and links rules.
- Chunk 2: input, textarea, select rules, then pre, code rules, then the [class*="bg-white"] and [class*="bg-secondary"] catch-all.
Reload the extension and observe the result
Your stylesheet is ready but the browser is still running the old (empty) version. You need to reload the extension so the browser picks up the new theme.css.
- Reload the extension using the instructions for your browser:
Chrome / Edge / Brave / Arc
- Navigate to chrome://extensions in a new tab.
- Find the NextWork Dark card and click the refresh icon next to the on/off toggle.
Orion
- Remove the extension from Tools > Extensions > Manage Extensions.
- Re-add it by clicking Install from Disk and selecting the nextwork-dark folder.
Why remove and re-add?
Orion caches the extension's manifest at install time. Disable/re-enable does not re-read files from disk. Removing and re-adding forces Orion to load everything fresh.
Before you navigate to NextWork, predict what you'll see. Will the page look different from before?
- Open https://nextwork.ai in a new tab (or refresh it if you already have it open).
The page background, text, headings, and most cards now render in dark tones. Your dark theme is working.
What should I notice?
Two things are deliberately incomplete at this stage:
- The theme is always on. There's no way to turn it off without removing the extension. You'll fix this in the next step by gating the CSS behind a data attribute.
- Some surfaces are still light. Reflection cards and certain coloured panels resist the stylesheet because they use Tailwind arbitrary values or layered !important declarations that outrank your rules. You'll fix these in Step 5 using computed-style measurement.
🙋♀️ Page still looks light?
- Make sure you saved theme.css before reloading the extension.
- Confirm you reloaded the extension (not just the page). CSS changes require an extension reload plus a page refresh.
- Open DevTools (F12) and check the Elements panel. Select the <html> element and look at applied styles. If you see your --nwd-canvas variable, the stylesheet loaded correctly.
Still stuck? Help me figure out why my theme.css isn't applying to the page.
Your extension now transforms NextWork into a dark-themed site the moment you visit it. Next up, you'll gate the CSS behind a toggle so users can switch between light and dark without a flash of white on page load.
Gate the Theme and Prevent the Flash
Right now the dark theme is stuck on. Every CSS rule applies unconditionally, so there is no way to turn it off.
The fix is a gate. You prefix every CSS rule with a selector that only matches when a specific data attribute is present on the <html> element. JavaScript controls that attribute, turning the entire theme on or off with a single DOM change.
In this step, get ready to:
- Gate every CSS rule behind a data attribute so the theme is off by default.
- Write the content script that reads localStorage synchronously and sets the gate before first paint.
- Test the gate manually in DevTools.
Gate the CSS
Every rule in theme.css currently applies to all elements unconditionally. You need to prefix each rule with html[data-nwdark="on"] so the stylesheet becomes inert until JavaScript opens the gate.
- Open theme.css in your editor.
- Delete all the existing content in the file.
- Paste in the gated variable declarations and base element rules:
html[data-nwdark="on"] {
color-scheme: dark;
--nwd-canvas: #1a1817;
--nwd-surface: #272320;
--nwd-raised: #302b27;
--nwd-well: #141211;
--nwd-line: #46403b;
--nwd-line-soft: #383330;
--nwd-text: #ede8e3;
--nwd-body: #cfc7c0;
--nwd-muted: #9c928a;
--nwd-link: #7fb3f2;
}
html[data-nwdark="on"],
html[data-nwdark="on"] body {
background-color: var(--nwd-canvas) !important;
color: var(--nwd-body) !important;
}
html[data-nwdark="on"] h1,
html[data-nwdark="on"] h2,
html[data-nwdark="on"] h3,
html[data-nwdark="on"] h4,
html[data-nwdark="on"] h5,
html[data-nwdark="on"] h6 {
color: var(--nwd-text) !important;
}
What does this code do?
- The html[data-nwdark="on"] selector means none of these rules fire unless that attribute exists on the <html> element.
- CSS custom properties (--nwd-canvas, --nwd-surface, etc.) define the dark palette once so every rule can reference them.
- The color-scheme: dark declaration tells the browser to render scrollbars and form controls in dark mode.
- Directly below the headings rule, paste the remaining gated rules for links, inputs, code blocks, and background-class elements:
html[data-nwdark="on"] a {
color: var(--nwd-link) !important;
}
html[data-nwdark="on"] input,
html[data-nwdark="on"] textarea,
html[data-nwdark="on"] select {
background-color: var(--nwd-surface) !important;
color: var(--nwd-text) !important;
border-color: var(--nwd-line) !important;
}
html[data-nwdark="on"] pre,
html[data-nwdark="on"] code,
html[data-nwdark="on"] [class*="code-block"] {
background-color: var(--nwd-well) !important;
color: var(--nwd-body) !important;
}
html[data-nwdark="on"] [class*="bg-white"],
html[data-nwdark="on"] [class*="bg-secondary"] {
background-color: var(--nwd-surface) !important;
}
What does this code do?
- Each rule targets a specific surface type (links, form inputs, code blocks, utility-class backgrounds) and overrides it with a dark palette colour.
- The !important declarations are necessary because NextWork's own Tailwind build uses !important internally.
- The [class*="bg-white"] selector catches any element with a class containing bg-white (e.g. Tailwind's bg-white class).
- Save theme.css.
- Reload the extension in your browser.
Reloading the extension
- Chrome, Edge, Brave, or Arc: Go to chrome://extensions and click the reload icon on your extension card.
- Orion: Remove the extension and re-add it from disk.
- Navigate to https://nextwork.ai.
The page should be light again. The stylesheet is loaded, but every rule is gated behind an attribute that nothing sets yet. The gate is closed.
Page still dark?
- Make sure you deleted all the old ungated rules from theme.css. Any leftover rule without the html[data-nwdark="on"] prefix still applies unconditionally.
- Confirm you reloaded the extension, not just the page. The browser caches the old CSS until the extension itself reloads.
- help me figure out why my gated CSS is still applying
Write content.js with the gate-attribute pattern
The gate attribute needs to be set before the browser paints the first frame. Otherwise the learner sees a flash of white on every navigation.
Why localStorage instead of chrome.storage?
chrome.storage is asynchronous. It cannot return a value before the browser renders the page. localStorage.getItem is synchronous and readable the instant the script runs at document_start.
The pattern: store a mirror of the user's preference in localStorage. Read it synchronously at document_start. Set the gate attribute before the first frame. Then reconcile with chrome.storage asynchronously as a correction path.
- Open content.js in your editor and paste the self-invoking function with the synchronous gate logic:
(function () {
var MIRROR_KEY = "nwd.gate";
var gate = readMirror();
var observing = false;
var sweepFn = null;
applyGate();
reconcile();
watchStorage();
function readMirror() {
try { return localStorage.getItem(MIRROR_KEY) || ""; } catch (e) { return ""; }
}
function writeMirror(value) {
try { localStorage.setItem(MIRROR_KEY, value); } catch (e) {}
}
function isDark() { return gate === "dark"; }
function applyGate() {
var root = document.documentElement;
if (!root) { observeRoot(); return; }
if (isDark()) { root.setAttribute("data-nwdark", "on"); }
else { root.removeAttribute("data-nwdark"); }
}
What does this code do?
- readMirror() reads localStorage synchronously the instant the script executes. This happens before the browser paints.
- applyGate() sets or removes data-nwdark="on" on the <html> element. If document.documentElement does not exist yet (rare edge case at document_start), it defers to a MutationObserver.
- sweepFn is a placeholder that a later step will use for the surface-measurement sweep.
- Directly below applyGate, paste the MutationObserver fallback and the gate-update function:
function observeRoot() {
if (observing) return;
observing = true;
var observer = new MutationObserver(function () {
if (!document.documentElement) return;
observer.disconnect();
observing = false;
applyGate();
});
observer.observe(document, { childList: true });
}
function setGate(value) {
if (value !== gate) { gate = value; writeMirror(value); }
applyGate();
if (sweepFn) sweepFn();
}
function reconcile() {
storageGet(["darkMode"], function (result) {
if (!result) return;
setGate(result.darkMode ? "dark" : "");
});
}
What does this code do?
- observeRoot() watches the document for the <html> element to appear, then immediately applies the gate. It disconnects itself after firing once.
- setGate() updates the in-memory gate value, writes it to the localStorage mirror, and re-applies the attribute. Other tabs calling this via chrome.storage.onChanged get instant sync.
- reconcile() reads chrome.storage.local (the async source of truth) and corrects the gate if it drifted from the mirror.
- Directly below reconcile, paste the storage listener and the cross-engine-compatible storage helper, then close the IIFE:
function watchStorage() {
try {
chrome.storage.onChanged.addListener(function (changes, area) {
if (area === "local") reconcile();
});
} catch (e) {}
}
function storageGet(keys, callback) {
var done = false;
function finish(result) {
if (done) return;
done = true;
callback(result && typeof result === "object" ? result : null);
}
var returned;
try { returned = chrome.storage.local.get(keys, finish); } catch (e) { finish(null); return; }
if (returned && typeof returned.then === "function") {
returned.then(finish, function () { finish(null); });
}
}
})();
Why the dual callback+promise pattern?
Chrome returns a promise from chrome.storage.local.get. Some browsers (like Orion, which uses WebKit internally) only support the callback style. The storageGet helper passes a callback AND handles a returned promise, with a once-guard so the result is processed exactly once regardless of which path fires.
- Save content.js.
✔️ Awesome, I've got everything!
Great. Make sure both theme.css and content.js are saved before moving on.
ⓧ I'd like to double check the full code
Your content.js should contain these sections in order (scroll up to confirm each one is present):
- The IIFE opening with MIRROR_KEY, gate, observing, sweepFn variables, three initial calls, then readMirror, writeMirror, isDark, and applyGate.
- observeRoot, setGate, and reconcile.
- watchStorage, storageGet, and the closing })();.
Your theme.css should have every rule prefixed with html[data-nwdark="on"] and no ungated rules remaining.
Test the gate
The gate is controlled by a localStorage key. You do not have a popup toggle yet, so you will test manually in DevTools.
- Reload the extension.
- Navigate to https://nextwork.ai (or refresh if already there).
- Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Console tab.
- Set the gate by running this command in the console:
localStorage.setItem("nwd.gate", "dark")
- Now navigate to another NextWork page (click any link on the site).
Before the page loads, what do you think will happen? Will there be a flash of white, or will the page render dark from the first frame?
The page renders dark instantly. No flash. The content script read the localStorage mirror synchronously at document_start and set the attribute before the browser painted.
- Now remove the gate by running this in the console:
localStorage.removeItem("nwd.gate")
- Navigate to another NextWork page.
The page renders light. The gate is closed and the stylesheet is inert again.
Page not going dark after setting localStorage?
- Make sure you navigated to a new page after setting the value. The content script only reads localStorage when it first loads (at document_start of a new navigation).
- Check that you typed the key exactly as nwd.gate with the value dark. A typo in either means the gate stays closed.
- Confirm you reloaded the extension after saving content.js. The old empty script is still injected until you reload.
- help me debug why my localStorage gate is not working
The gate pattern is working. You can flip the theme on and off, and it renders dark from the very first frame with no flash. Next up, you will build a popup toggle so you do not have to open DevTools every time.
Build the Popup Toggle
Your gate works perfectly. You proved it in DevTools by manually writing to localStorage. But asking users to open DevTools every time they want dark mode is not a real product.
In this step, you will build a popup toggle that writes to chrome.storage (which broadcasts to all tabs) and mirrors to localStorage (so the next page load is flash-free). The toggle uses an accessible label element so the entire switch area is clickable.
In this step, get ready to:
- Build the popup HTML structure with an accessible toggle switch.
- Style the popup with a dark theme and animated toggle.
- Wire the toggle to chrome.storage so preferences persist across tabs.
Write the popup HTML
The popup is the small panel that appears when you click the extension icon. It needs a title, a toggle switch, and a status line.
- In your editor, open popup.html inside the nextwork-dark folder.
- Replace everything in the file with this markup:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NextWork Dark</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<h1 class="title">NextWork Dark</h1>
<ul class="rows">
<li class="row">
<!-- label wraps the entire switch for accessibility -->
<label class="switch" for="dark-toggle">
<span class="switch__text">
<span class="switch__name">Dark Mode</span>
<span class="switch__hint">Theme nextwork.ai pages</span>
</span>
<span class="switch__control">
<input type="checkbox" id="dark-toggle" class="switch__input">
<span class="switch__track">
<span class="switch__thumb"></span>
</span>
</span>
</label>
</li>
</ul>
<p id="status" class="status">Loading</p>
<script src="popup.js"></script>
</body>
</html>
What does this structure do?
- The <label> wraps the entire switch. Because it has for="dark-toggle", clicking anywhere inside the label toggles the checkbox.
- The <input> checkbox will be invisible but full-size, so the entire switch area responds to clicks.
- The switch__track and switch__thumb spans are purely visual. CSS animates them based on the checkbox's :checked state.
- Save popup.html.
- Reload the extension (click the reload icon on chrome://extensions, or remove and re-add on Orion).
- Click the extension icon in your toolbar. You should see an unstyled popup with the text "NextWork Dark", "Dark Mode", and a native checkbox.
Don't see the popup?
- Make sure you saved the file and reloaded the extension. Unsaved HTML changes will not appear.
- Check that popup.html is in the root of your nextwork-dark folder (not inside a subfolder).
- help me debug why my popup is not appearing
Style the popup with a dark theme
The popup needs its own stylesheet. It will use the same warm dark palette as the page theme so everything feels cohesive.
- Open popup.css and add the colour variables, reset, body sizing, and base styles:
/* Palette matching the page theme */
:root {
--nwd-canvas: #1a1817;
--nwd-surface: #272320;
--nwd-line: #46403b;
--nwd-line-soft: #383330;
--nwd-text: #ede8e3;
--nwd-body: #cfc7c0;
--nwd-muted: #9c928a;
--nwd-accent: #d9a54a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
/* Fixed width keeps the popup compact */
body {
width: 280px;
padding: 18px 20px 16px;
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--nwd-canvas);
color: var(--nwd-body);
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--nwd-text);
padding-bottom: 14px;
}
.rows { list-style: none; }
.row { border-top: 1px solid var(--nwd-line-soft); }
What does this code do?
- The :root block defines CSS variables so every colour lives in one place.
- Setting width: 280px on the body constrains the popup to a tidy card shape.
- The --nwd-accent gold colour will highlight the toggle when it is on.
- Below the .row rule, add the switch layout and text styles:
/* Flex row: text on left, toggle on right */
.switch {
display: flex;
align-items: center;
gap: 14px;
padding: 13px 2px;
cursor: pointer;
border-radius: 8px;
user-select: none;
transition: background-color 0.15s ease;
}
.switch:hover { background-color: var(--nwd-surface); }
.switch__text { flex: 1 1 auto; }
.switch__name {
display: block;
font-size: 13.5px;
font-weight: 500;
color: var(--nwd-text);
}
.switch__hint {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--nwd-muted);
}
What does this code do?
The .switch is a flex container. Text fills the left side and the toggle control sits on the right. The hover state gives subtle feedback that the whole row is clickable.
- Save popup.css and reload the extension.
- Click the extension icon. You should see a dark popup with the title "NextWork Dark" and a row showing "Dark Mode" with a hint beneath it.
The toggle itself is still missing. That is the next chunk.
- Back in popup.css, add the switch control container and the invisible checkbox below your existing rules:
/* Container for the toggle switch */
.switch__control {
position: relative;
flex: none;
width: 42px;
height: 24px;
}
/* Invisible checkbox covers the full switch area */
.switch__input {
appearance: none;
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 1;
}
Why is the checkbox invisible but full-size?
The checkbox is positioned over the entire switch area with opacity: 0 and z-index: 1. It is invisible but captures every click. This is better than shrinking it to width: 0; height: 0 because a zero-size element has no hit area.
- Below the .switch__input rule, add the track, thumb, checked states, focus ring, and status styles:
/* Track (pill-shaped background) */
.switch__track {
position: absolute;
inset: 0;
border-radius: 24px;
background: #3d3733;
border: 1px solid var(--nwd-line);
transition: background-color 0.22s ease, border-color 0.22s ease;
}
/* Thumb (circle that slides) */
.switch__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #cfc7c0;
transition: transform 0.22s ease, background-color 0.22s ease;
}
/* Active state when checked */
.switch__input:checked + .switch__track {
background: var(--nwd-accent);
border-color: #e0b464;
}
.switch__input:checked + .switch__track .switch__thumb {
background: #1a1817;
transform: translateX(18px);
}
How do the sibling selectors work?
The :checked + .switch__track selector targets the track span that immediately follows a checked input. When the user clicks, the browser toggles the checkbox's checked state. CSS reacts by changing the track colour and sliding the thumb 18 pixels to the right.
- Finally, add the focus ring and status paragraph styles at the bottom of popup.css:
/* Keyboard focus indicator */
.switch__input:focus-visible + .switch__track {
outline: 2px solid var(--nwd-accent);
outline-offset: 2px;
}
/* Status text at the bottom */
.status {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--nwd-line-soft);
font-size: 11px;
color: var(--nwd-muted);
text-align: center;
}
What does this code do?
- The :focus-visible rule shows an outline only when the user navigates with the keyboard, not on mouse clicks.
- The .status paragraph will show "Dark mode on" or "Dark mode off" once JavaScript runs.
- Save popup.css and reload the extension.
- Click the extension icon. You should see a polished dark popup with a toggle switch. Click the switch area. The thumb should slide right and turn gold.
Toggle not sliding?
- Make sure the .switch__input rule has z-index: 1. Without it, the track sits on top of the checkbox and swallows clicks.
- Confirm the selectors use the adjacent sibling combinator (+) not a descendant selector. The track must immediately follow the input in the HTML.
- help me debug why my toggle switch is not responding to clicks
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your popup.css should contain these sections in order. Scroll up and confirm each chunk is present:
- Root variables, reset, body sizing, title, rows, and row styles.
- Switch flex layout, hover, text, name, and hint styles.
- Switch control container and invisible full-size input.
- Track, thumb, checked states with translateX.
- Focus-visible ring and status paragraph.
Wire the toggle to chrome.storage
The toggle looks right, but clicking it does nothing to the page. You need JavaScript that reads the preference on load, writes it when the user clicks, and listens for changes from other tabs.
- Open popup.js and add the main logic: element references, the change listener, and the storage watchers:
(function () {
var dark = document.getElementById("dark-toggle");
var status = document.getElementById("status");
var darkMode = false;
// When user clicks the toggle, persist the new state
dark.addEventListener("change", function () {
darkMode = dark.checked;
render();
storageSet({ darkMode: darkMode });
});
load();
watchStorage();
// Read the current preference from chrome.storage
function load() {
storageGet(["darkMode"], function (result) {
if (result) darkMode = !!result.darkMode;
render();
});
}
// Sync if another tab changes the value
function watchStorage() {
try {
chrome.storage.onChanged.addListener(function (changes, area) {
if (area !== "local" || !changes.darkMode) return;
darkMode = !!changes.darkMode.newValue;
render();
});
} catch (e) {}
}
What does this code do?
- The change listener fires when the checkbox toggles. It writes the new boolean to chrome.storage.local, which triggers onChanged in every open content script.
- The load() function reads the stored preference so the toggle shows the correct state when the popup opens.
- The watchStorage() listener keeps the popup in sync if the preference changes while the popup is open (from a keyboard shortcut or another window).
- Directly below the watchStorage function, add the render helper and the dual-pattern storage functions that close the IIFE:
// Update the UI to match current state
function render() {
dark.checked = darkMode;
status.textContent = darkMode ? "Dark mode on" : "Dark mode off";
}
// Dual-pattern read: callback + promise for cross-engine compat
function storageGet(keys, callback) {
var done = false;
function finish(result) {
if (done) return;
done = true;
callback(result && typeof result === "object" ? result : null);
}
var returned;
try { returned = chrome.storage.local.get(keys, finish); } catch (e) { finish(null); return; }
if (returned && typeof returned.then === "function") {
returned.then(finish, function () { finish(null); });
}
}
// Dual-pattern write
function storageSet(value) {
var returned;
try { returned = chrome.storage.local.set(value, function () {}); } catch (e) { return; }
if (returned && typeof returned.then === "function") {
returned.then(null, function () {});
}
}
})();
Why the dual callback+promise pattern?
Chrome's chrome.storage.local.get returns a promise in modern Chrome. But some browsers (like Orion, which uses WebKit) only support the older callback style. Passing a callback AND handling the returned promise means the code works everywhere without hanging.
- Save popup.js.
- Reload the extension.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before testing.
ⓧ I'd like to double check the full code
Your popup.js should contain these sections in order. Scroll up and confirm each is present:
- Self-invoking function wrapper opening.
- Element references (dark, status) and darkMode boolean.
- Change event listener calling render() and storageSet().
- load() and watchStorage() calls.
- load() function reading from storageGet.
- watchStorage() function with onChanged listener.
- render() function updating checkbox and status text.
- storageGet() and storageSet() with dual callback+promise pattern.
- Closing })(); to end the IIFE.
Fix the click-during-load race
There is a subtle bug hiding in the code you just wrote. The load() function reads storage asynchronously. If you click the toggle before that read finishes, the callback fires afterwards and overwrites your click with the stale stored value. The switch snaps back to its old position.
A loaded flag fixes this in three lines.
- In popup.js, find the variable declarations and the change listener at the top of the IIFE:
var darkMode = false;
// When user clicks the toggle, persist the new state
dark.addEventListener("change", function () {
darkMode = dark.checked;
- Add var loaded = false; below darkMode, and add loaded = true; as the first line inside the change callback, so it looks like:
var darkMode = false;
var loaded = false;
// When user clicks the toggle, persist the new state
dark.addEventListener("change", function () {
loaded = true;
darkMode = dark.checked;
- Now find the load() function:
function load() {
storageGet(["darkMode"], function (result) {
if (result) darkMode = !!result.darkMode;
render();
});
}
- Add a guard at the top of the callback so it bails out if the user already clicked:
function load() {
storageGet(["darkMode"], function (result) {
if (loaded) return;
loaded = true;
if (result) darkMode = !!result.darkMode;
render();
});
}
Why does this fix the race?
The loaded flag acts as a one-way latch. Whichever fires first — the storage callback or the user's click — wins and sets loaded = true. The slower one sees the flag and bails out.
If the user clicks before storage responds, the click sets the authoritative state and the stale callback is harmless. If storage responds first (the normal case), the user sees the correct initial position and their next click proceeds normally.
Time to test the full flow. Before you click the toggle, what do you think will happen to the NextWork page when you flip it on?
- Open a tab to https://nextwork.ai.
- Click the extension icon and flip the toggle on.
The NextWork page goes dark immediately. The status line reads "Dark mode on".
- Open a second tab to any other NextWork page (a project page, the explore page, etc.).
The second tab loads dark from the very first frame. No flash of white. The localStorage mirror you built in the previous step fires before the browser paints.
- Flip the toggle off. Both tabs go light.
Page not going dark when you toggle?
- Open DevTools on the NextWork tab and check the console for errors. A common cause is a typo in chrome.storage.local.set inside popup.js.
- Make sure content.js has the watchStorage() call that listens for chrome.storage.onChanged. Without it the content script never hears the popup's write.
- If the first tab works but the second tab flashes white, check that content.js calls writeMirror(value) inside setGate(). The mirror must be updated for the synchronous read to work on the next page load.
- help me debug why my popup toggle is not changing the page theme
Your toggle is fully wired. Click the icon, flip the switch, and every NextWork tab responds. Next up, you will tackle the surfaces that CSS alone cannot reach.
Measure and Fix Unreachable Surfaces
Your toggle works and most of the page goes dark. But some surfaces stubbornly stay light.
The problem is that CSS selectors alone cannot reach every element. NextWork uses Tailwind arbitrary-value classes like bg-[#f0ebe9] (no predictable class name to target) and declares !important inside @layer (which beats an unlayered !important regardless of specificity). The only way to fix these surfaces is to measure their computed colour at runtime and write inline styles directly.
In this step, get ready to:
- Add a luminance function that measures how bright a computed colour is.
- Build a sweep that finds still-light elements and paints them dark with inline styles.
- Add a MutationObserver so new elements get caught as they appear.
Add the luminance helper and sweep function
The sweep lives inside a single enclosing function called sweepLightSurfaces(). It sets up helpers, runs an initial pass, and starts observing for new elements.
- In content.js, scroll to the bottom of the file. Find the closing })(); of the IIFE.
- Just above that closing line, add the opening scaffold of the sweep function:
function sweepLightSurfaces() {
var LIGHT_THRESHOLD = 150;
var scheduled = false;
sweepFn = run;
run();
if (document.readyState !== "complete") {
document.addEventListener("DOMContentLoaded", run);
window.addEventListener("load", run);
}
observeMutations();
function luminance(color) {
var m = String(color).match(/(\d+(?:\.\d+)?)/g);
if (!m || m.length < 3) return null;
if (m.length > 3 && parseFloat(m[3]) < 0.15) return null;
return 0.2126 * +m[0] + 0.7152 * +m[1] + 0.0722 * +m[2];
}
function run() {
if (!document.body) return;
if (!isDark()) { revert(); return; }
var all = document.body.querySelectorAll("*");
for (var i = 0; i < all.length; i++) tag(all[i]);
}
What does this code do?
- LIGHT_THRESHOLD = 150 is the brightness cutoff. Any element with a computed luminance above 150 (out of 255) is considered "still light" and needs painting.
- sweepFn = run assigns the run function to the outer-scope variable so setGate() (which already has if (sweepFn) sweepFn()) can trigger re-sweeps whenever dark mode toggles.
- luminance() extracts RGB values from a computed colour string and returns a weighted brightness score. It returns null for near-transparent elements (alpha below 0.15) to avoid false positives.
- run() queries every element in the body and passes each to tag() for measurement. If dark mode is off, it calls revert() to clean up inline styles.
- Directly below the run() function you just added, paste the element-processing functions:
function tag(el) {
if (el.hasAttribute("data-nwd-lit")) return;
var cs = getComputedStyle(el);
var lum = luminance(cs.backgroundColor);
if (lum !== null && lum > LIGHT_THRESHOLD) { paint(el); return; }
var image = cs.backgroundImage;
if (image && image !== "none" && image.indexOf("gradient") !== -1) {
var stops = image.match(/rgba?\([^)]+\)/g) || [];
for (var j = 0; j < stops.length; j++) {
if (luminance(stops[j]) !== null && luminance(stops[j]) > LIGHT_THRESHOLD) {
el.style.setProperty("background-image", "none", "important");
el.setAttribute("data-nwd-lit", "");
return;
}
}
}
}
function paint(el) {
el.setAttribute("data-nwd-lit", "");
el.style.setProperty("background-color", "#272320", "important");
el.style.setProperty("background-image", "none", "important");
}
What does this code do?
- tag() is the per-element check. It skips anything already marked with data-nwd-lit, reads the computed background colour, and calls paint() if the luminance is above threshold.
- It also checks backgroundImage for gradients with light colour stops. CSS selectors cannot target arbitrary gradient values, so the only way to detect them is by reading the computed style.
- paint() writes inline styles with !important (the only way to override a layered !important from Tailwind's @layer) and marks the element with data-nwd-lit so it is not re-processed.
- Save content.js.
- Reload the extension (click the reload icon on chrome://extensions, or remove and re-add for Orion).
- Open any NextWork page and confirm dark mode still applies with no console errors. Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) and check the Console tab.
Seeing a syntax error in the console?
Make sure the function run() block ends with a closing brace before function tag() starts. A missing brace makes the parser choke on the next function declaration.
Also confirm you pasted the code inside sweepLightSurfaces() (between its opening brace and where the closing brace will go), not outside the IIFE.
Help me debug a syntax error in my content.js sweep function.
Handle gradients and add a MutationObserver
The sweep can now find and paint bright elements. But it also needs to undo its work when dark mode turns off, and re-run whenever new DOM nodes appear (NextWork loads content dynamically).
- In content.js, directly below the paint() function you just added, paste the revert path and mutation observer:
function revert() {
var painted = document.querySelectorAll("[data-nwd-lit]");
for (var i = 0; i < painted.length; i++) {
var el = painted[i];
el.removeAttribute("data-nwd-lit");
el.style.removeProperty("background-color");
el.style.removeProperty("background-image");
}
}
function observeMutations() {
if (typeof MutationObserver !== "function") return;
new MutationObserver(function () {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(function () {
scheduled = false;
run();
});
}).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "data-nwdark"]
});
}
}
What does this code do?
- revert() finds every element marked with data-nwd-lit and removes both the attribute and the inline styles. This runs when dark mode is toggled off, restoring the original appearance.
- observeMutations() watches for DOM changes (new elements loaded, class changes, style changes). When it detects a change, it schedules a re-sweep via requestAnimationFrame so multiple rapid mutations batch into a single sweep instead of thrashing the layout.
- The closing } at the end closes the sweepLightSurfaces() function.
- Save content.js.
Wire the sweep to the gate
The function is fully defined, but nothing calls it yet. One line wires it into the startup sequence so it runs alongside the gate and storage listeners.
- Near the top of content.js, find these three lines inside the IIFE:
applyGate();
reconcile();
watchStorage();
- Add sweepLightSurfaces(); on a new line directly after watchStorage();, so the block looks like this:
applyGate();
reconcile();
watchStorage();
sweepLightSurfaces();
- Save content.js.
- Reload the extension.
Before you navigate, think about what you expect. In the previous steps, some surfaces (reflection cards, coloured panels) stayed light even with dark mode on. Do you think those surfaces will be dark now?
- Navigate to a NextWork project page that has reflection cards (any project with a learning reflection section).
Every surface renders dark. The reflection cards, arbitrary-value panels, and gradient elements that were still light before are now themed. The sweep measured their computed colours, found them above the brightness threshold, and painted them with inline styles.
- Flip the toggle off in the popup. The page reverts to its original light appearance with no leftover dark patches.
- Flip it back on. The surfaces go dark again instantly.
Still seeing light surfaces after reload?
- Make sure sweepLightSurfaces(); appears after watchStorage(); inside the IIFE. If it is inside another function, it will not run at startup.
- Check that the closing brace of sweepLightSurfaces() sits before the final })(); of the IIFE. A misplaced brace can silently break the function without a console error.
- Open DevTools and look for errors. A reference error like revert is not defined means one of the inner functions was pasted outside sweepLightSurfaces().
Help me debug why my sweep isn't painting light surfaces dark.
💡 Why can't CSS alone fix these surfaces?
NextWork's Tailwind build declares !important inside @layer utilities. Per the CSS Cascade specification, a layered !important beats an unlayered !important regardless of selector specificity. Your stylesheet rules lose every time.
Arbitrary-value classes like bg-[#f0ebe9] have no predictable class name to target with a selector. The only universal fix is to read getComputedStyle at runtime and write inline styles directly on the element.
✔️ Awesome, I've got everything!
Great. Double check you have saved content.js and the extension is reloaded.
ⓧ I'd like to double check the full code
Your content.js is now too long to display in one block. Scroll through your file and confirm these sections are present, in order:
- The IIFE opening with MIRROR_KEY, gate, observing, and sweepFn variables.
- The four startup calls: applyGate(), reconcile(), watchStorage(), sweepLightSurfaces().
- The helper functions: readMirror, writeMirror, isDark, applyGate, observeRoot, setGate, reconcile, watchStorage, storageGet.
- The sweepLightSurfaces() function containing: luminance, run, tag, paint, revert, and observeMutations.
- The closing })(); of the IIFE as the very last line.
What about shadow DOM components?
You may still see light interiors inside some web components on NextWork pages. The sweep uses document.body.querySelectorAll("*") which cannot cross shadow DOM boundaries. Elements inside a shadow root are invisible to that query.
The full solution involves walking every shadowRoot recursively and injecting styles via adoptedStyleSheets. On a production site with 1000+ shadow roots that adds significant complexity. For this project, the sweep handles the vast majority of surfaces. Shadow DOM piercing is the next frontier if you want to take coverage to 100%.
Your extension now handles every surface on the page. CSS covers the predictable elements, and the runtime sweep catches everything CSS cannot reach. Next up, you have a secret mission to make toggling even faster with a keyboard shortcut.
Secret mission
Add a Keyboard Shortcut
Your dark mode toggle works great from the popup. But power users want speed. What if you could toggle dark mode with a single keyboard shortcut, without ever clicking the extension icon?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in your browser with no external services or paid resources. There are no ongoing costs.
Decide whether to keep the extension loaded, disable it temporarily, or remove it entirely.
Resources you used:
- The nextwork-dark extension loaded in your browser.
- The nextwork-dark project folder on your Desktop.
Keep everything running
No action needed. Choose this if you want to keep using dark mode on NextWork.
- Leave the extension loaded. It only activates on nextwork.ai pages and uses no background resources when idle.
- Your nextwork-dark folder stays on your Desktop for future edits.
- Want more features? Clone the full version at https://github.com/ZAG23/nextwork-dark which adds image dimming, shadow DOM piercing, and cross-engine compatibility hardening.
Pause - I'll come back to this later
Disable the extension without removing it. Your files stay on disk and you can re-enable any time.
- Open your browser's extensions page (chrome://extensions for Chrome, Edge, Brave, or Arc).
- Find the NextWork Dark card and toggle the switch off.
- The extension is now disabled. Toggle it back on whenever you want to use it again.
Delete - I don't want to use this again
Remove the extension from your browser and delete the project folder from your machine.
- Open your browser's extensions page (chrome://extensions for Chrome, Edge, Brave, or Arc).
- Find the NextWork Dark card and click Remove.
- Confirm the removal when prompted.
Delete the project folder from your Desktop by running one of these commands:
macOS
rm -rf ~/Desktop/nextwork-dark
Windows
rmdir /s /q %USERPROFILE%\Desktop\nextwork-dark
Optionally, clear the orphaned localStorage entry on nextwork.ai. Open DevTools on any nextwork.ai page and run this in the console:
localStorage.removeItem("nwd.gate")
This removes the dark mode preference that the content script stored on the nextwork.ai origin.
Nice Work!
Nice Work!
You just built a fully functional dark mode browser extension from scratch. No build tools, no frameworks, no dependencies.
You've learned how to:
- Build a Manifest V3 browser extension from scratch that themes a live website with a warm dark palette, no build step required.
- Prevent the flash of white on page load using the gate-attribute pattern: CSS loads at document_start but stays inert until JavaScript opens the gate synchronously via a localStorage mirror.
- Measure computed styles to fix surfaces that CSS selectors cannot reach, including layered !important declarations, arbitrary-value Tailwind classes, and gradient stops.
- Secret Mission: Added a keyboard shortcut via the commands API with a minimal service worker that dark mode does not depend on.
Ready to quiz yourself?