Why I Care About This
In 2022, I had life-changing eye surgery — lens replacement. Before the surgery, I could not read bright white screens without significant pain and discomfort. Dark Reader became a non-negotiable part of my browser setup. Every site I visited got inverted whether the developers intended it or not. But sometimes a web offers a dark mode toggle but it actually conflict with DarkReader's efforts.
I'm not alone. Millions of people depend on Dark Reader and similar extensions for accessibility reasons — photosensitivity, migraines, recovering from eye surgery, or simply preferring low-luminance environments. Many of them have no control over their viewing conditions. Think about screen sharing: when someone else is presenting, you're stuck with whatever theme their machine is running.
With the new artificial lens in each eye now, I can handle bright screens again — though I still prefer dark. But the issue is still important to me, because #empathy is an important value.
But building this site gave me a fresh appreciation for the problem from the other side: what happens when your site's dark mode toggle and Dark Reader are both active at the same time?
The short answer: they fight, and it looks terrible.
The Problem
When you build a dark mode toggle into your site, you're typically doing something like this:
// Set dark mode
document.documentElement.setAttribute('data-theme', 'dark');
// Set light mode
document.documentElement.removeAttribute('data-theme');
And your CSS looks like:
:root {
--bg-main: #fdf8f2;
--text-primary: #2c1a0e;
}
[data-theme="dark"] {
--bg-main: #0f150f;
--text-primary: #00ff9f;
}
This works beautifully on its own. But Dark Reader works by injecting its own stylesheet into the page — it analyzes your colors, inverts them, and applies its own data-darkreader-scheme attribute to <html>. If your toggle is also active, both stylesheets are fighting over the same elements. The result is an ugly, inconsistent mess: some things are double-inverted back to light, others are a muddy combination of both themes.
The polite solution is to detect that Dark Reader is present and step aside.
Detecting Dark Reader
Dark Reader signals its presence by adding a data-darkreader-scheme attribute to the <html> element. You can check for it directly:
const isDarkReaderActive = () =>
document.documentElement.hasAttribute('data-darkreader-scheme');
The catch: Dark Reader injects asynchronously after the page loads. If you check at DOMContentLoaded, you'll often check before DR has done anything. A simple setTimeout workaround is fragile — network speed, extension load order, and browser quirks all affect timing.
The right event to catch these changes is called a MutationObserver. It watches the DOM for changes and fires a callback the instant Dark Reader adds or removes its attribute — no polling, no race conditions, NO EXCUSES!
The Solution
Here's the complete dark mode toggle script with Dark Reader awareness built in:
(function() {
const themeToggle = document.getElementById('theme-toggle');
const themeToggleText = document.getElementById('theme-toggle-text');
const html = document.documentElement;
const setTheme = (theme) => {
if (theme === 'dark') {
html.setAttribute('data-theme', 'dark');
themeToggleText.textContent = 'Light';
localStorage.setItem('theme', 'dark');
} else {
html.removeAttribute('data-theme');
themeToggleText.textContent = 'Dark';
localStorage.setItem('theme', 'light');
}
};
// Restore saved preference on load
setTheme(localStorage.getItem('theme') || 'light');
themeToggle.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
setTheme(current === 'dark' ? 'light' : 'dark');
});
// Hide our toggle if Dark Reader is active — it's already doing the job.
// Use visibility:hidden instead of display:none to preserve layout spacing.
const checkDarkReader = () => {
const drActive = html.hasAttribute('data-darkreader-scheme');
themeToggle.style.visibility = drActive ? 'hidden' : '';
};
checkDarkReader();
new MutationObserver(checkDarkReader).observe(html, {
attributes: true,
attributeFilter: ['data-darkreader-scheme']
});
})();
The key decisions:
attributeFilter: ['data-darkreader-scheme'] — We're only watching for the one attribute we care about. Without this filter, the observer would fire on any attribute change to <html>, including our own data-theme toggles, causing unnecessary callbacks.
visibility: hidden instead of display: none — This is a subtle but important distinction. display: none removes the element from the layout flow entirely. If your toggle button is a flex child in your nav bar, hiding it with display: none collapses that slot and causes the remaining nav items to shift. visibility: hidden makes the element invisible while preserving its space in the layout — the nav stays perfectly centered regardless of whether the button is visible.
/* What we want — button disappears, space remains */
themeToggle.style.visibility = 'hidden'; /* ✓ */
/* What we don't want — button disappears, nav shifts */
themeToggle.style.display = 'none'; /* ✗ */
The HTML
Your toggle button just needs an ID and a text span to update:
<button id="theme-toggle" aria-label="Toggle dark mode">
<span id="theme-toggle-text">Dark</span>
</button>
When Dark Reader is active, the button is invisible but still in the DOM, still taking up space, and the MutationObserver is still watching. The moment a user disables Dark Reader, the observer fires, the button reappears, and your toggle is fully functional again.
A Note on Accessibility
If you're building for an audience that includes people with photosensitivity or visual impairments, a well-implemented dark mode isn't just a nice-to-have — it's a courtesy. Dark Reader exists because browsers and operating systems were late to the party on system-level dark mode, and many sites still don't respect prefers-color-scheme at all.
The approach above respects the user's choice: if they've installed Dark Reader, they've made a deliberate decision about how they want to see the web. Your toggle doesn't fight them — it just quietly gets out of the way.
If you want to go further, you can also respect the system preference on first load by checking prefers-color-scheme before falling back to your saved localStorage value:
const getInitialTheme = () => {
const saved = localStorage.getItem('theme');
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
};
Small things. Big difference for the people who need them.