How to Enable Dark Mode on a Web Page
Learn how to add system-aware dark mode, a manual theme toggle, accessible colors, and reliable testing to any web page.
There are two different dark-mode problems:
- You own the page: add a light and dark palette with
prefers-color-scheme, declarecolor-scheme, and optionally provide a manual toggle. - You are reading a page: dark mode works only when the site supplies a dark theme. Otherwise use your browser’s force-dark feature or an extension, knowing that automatic inversion can damage colors, images and controls.
The implementation below follows the browser’s operating-system preference, gives browser UI the right scheme, avoids a flash of the wrong theme, and supports a persistent user override.
1. Add system-aware dark mode
prefers-color-scheme detects whether the user agent or operating system requests a light or dark theme. It does not invent dark colors; your CSS must define them. The feature is widely available across browsers.
Declare the supported schemes in both the document head and the root element. The declaration helps browser-provided UI such as scrollbars and form controls match the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--surface: #f4f6f8;
--fg: #111111;
--muted: #4b5563;
--link: #0645ad;
--border: #d1d5db;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--surface: #1b1b1b;
--fg: #eeeeee;
--muted: #b6beca;
--link: #8ab4f8;
--border: #3f4650;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 1rem/1.6 system-ui, sans-serif;
}
main { max-width: 70rem; margin: auto; padding: 2rem; }
.card { background: var(--surface); border: 1px solid var(--border); padding: 1rem; }
a { color: var(--link); }
</style>
</head>
<body>
<main>
<h1>Theme-aware page</h1>
<div class="card">This panel follows the system preference.</div>
</main>
</body>
</html>
Use design tokens (custom properties) for every color instead of scattering literal values through selectors. That makes the dark palette complete and keeps components consistent.
2. Add a light, dark, or system toggle
A media query is automatic, but some users need a choice that overrides the system. Store one of three values: light, dark, or system. When the value is system, remove the override and let prefers-color-scheme decide.
Set the saved preference before the page paints when possible. This reduces a flash of the wrong scheme on repeat visits.
<script>
(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.dataset.theme = saved;
}
})();
</script>
<style>
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] {
--bg: #ffffff; --surface: #f4f6f8; --fg: #111111;
--muted: #4b5563; --link: #0645ad; --border: #d1d5db;
}
:root[data-theme="dark"] {
--bg: #111111; --surface: #1b1b1b; --fg: #eeeeee;
--muted: #b6beca; --link: #8ab4f8; --border: #3f4650;
}
</style>
<button id="theme-toggle" type="button" aria-pressed="false">
Use dark mode
</button>
<select id="theme-choice" aria-label="Color theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<script>
const root = document.documentElement;
const choice = document.querySelector('#theme-choice');
const toggle = document.querySelector('#theme-toggle');
const saved = localStorage.getItem('theme') || 'system';
choice.value = saved;
apply(saved);
function apply(theme) {
if (theme === 'system') root.removeAttribute('data-theme');
else root.dataset.theme = theme;
const dark = theme === 'dark' ||
(theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
toggle.setAttribute('aria-pressed', String(dark));
toggle.textContent = dark ? 'Use light mode' : 'Use dark mode';
}
choice.addEventListener('change', () => {
localStorage.setItem('theme', choice.value);
apply(choice.value);
});
toggle.addEventListener('click', () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
choice.value = next;
localStorage.setItem('theme', next);
apply(next);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if ((localStorage.getItem('theme') || 'system') === 'system') apply('system');
});
</script>
Keep the toggle keyboard accessible, expose its state with aria-pressed, and provide a visible focus style. If you offer a three-way selector, label “System” clearly so users can return to automatic synchronization.
3. Cover components, images, forms, and embedded content
Changing only body leaves cards, code blocks, dialogs, inputs and SVGs unreadable. Map every surface and foreground token used by your components.
- Use a dark background with a readable text color; do not rely on pure black and pure white for every surface.
- Set border and placeholder colors explicitly.
- Give code samples, tables, alerts and navigation their own surface tokens.
- Check inline SVG fills and strokes. Prefer
currentColorwhere appropriate. - Do not blindly invert photographs, product screenshots or brand artwork.
- For third-party iframes, the embedded document controls its own theme.
input, textarea, select, button {
color: var(--fg);
background: var(--surface);
border: 1px solid var(--border);
}
::placeholder { color: var(--muted); }
svg { color: var(--fg); }
@media (prefers-color-scheme: dark) {
img { /* Keep photographs unchanged unless you have a dark asset. */ }
}
4. Test dark mode before shipping
- Test automatic mode with the browser’s emulated
prefers-color-schemesetting. - Switch the operating-system theme while the page is open and confirm that “System” updates.
- Test every manual choice, then reload and confirm persistence.
- Use keyboard navigation and inspect focus indicators in both schemes.
- Review text, links, disabled controls, borders, charts, syntax highlighting, images and modal overlays.
- Test pages with JavaScript disabled or delayed to catch theme flashes and unreadable default styles.
For automated checks, render the page twice with a light and dark color-scheme preference and compare screenshots. Check contrast with an accessibility tool, but also inspect real content: contrast tools cannot tell whether an inverted image or chart is misleading.
5. What readers can do when a site has no dark theme
A website cannot respond to prefers-color-scheme unless its author supplies a dark palette. A browser’s built-in force-dark setting or an extension can attempt automatic inversion, but results vary. Photos, logos, syntax highlighting, shadows and carefully chosen brand colors may be changed incorrectly. Disable forced inversion for pages where readability or visual accuracy suffers.
6. Or skip the browser setup
If your goal is to capture a page in dark mode rather than build the theme yourself, ScreenshotNeo can request a dark-mode screenshot through its API. See the ScreenshotNeo documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Set the dark-mode option in the request when you need a dark capture. ScreenshotNeo removes cookie banners, newsletter popups and chat widgets before the shot. Bot checks, blank pages and failed loads are never billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor and other MCP clients take screenshots. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account and start with the monthly free allowance.
7. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Dark mode never appears | The stylesheet has no dark palette or the media query is overridden. | Inspect computed styles, confirm the media query matches, and define variables for every visible surface. |
| Controls stay light | The page did not declare color-scheme, or controls have hard-coded colors. |
Add the meta and root declarations, then style form controls with theme tokens. |
| The page flashes light before becoming dark | The saved preference is applied after first paint. | Run the small localStorage script in the document head and avoid blocking it on a large bundle. |
| System changes are ignored | A manual override is still stored. | Provide a “System” choice that removes data-theme and listen for media-query changes. |
| Images look wrong in forced dark mode | The browser is inverting pixels automatically. | Prefer an author-provided dark theme or disable forced inversion for that page. |
| Text is readable but borders disappear | Borders use a light-only literal color. | Move border colors into the dark token set and test disabled and hover states. |
8. Performance, reliability, and cost
A CSS media query has negligible runtime cost. A manual toggle adds only a small synchronous preference read and a class or attribute update. Keep the early script tiny, avoid rebuilding the page, and change variables rather than replacing large stylesheets.
For reliable rendering, define a default light palette in the base rule, then override it for dark mode. This prevents unstyled content when CSS loads late. Use stable tokens so new components inherit a complete theme. Cache the user’s choice locally, but treat it as a preference rather than account data unless you also synchronize it server-side.
Screenshot capture adds network and page-load time because the target page must load before rendering. Use a selector or network-idle wait for dynamic pages, and cache captures with a suitable TTL when the page does not change often. ScreenshotNeo bills only clean shots; bot checks, blank pages, timeouts, failed loads and cache hits cost nothing.
9. FAQ
Does prefers-color-scheme force every website dark?
No. It only reports a preference. The site must provide dark CSS.
Should I use a class or data-theme attribute?
Either works. An attribute makes the three states—system, light and dark—easy to represent explicitly.
Can I detect the current system preference in JavaScript?
Yes. Use matchMedia('(prefers-color-scheme: dark)').matches and listen for its change event when no manual override is active.
Why declare both the meta element and the CSS property?
The meta declaration communicates support early to the user agent; the CSS property applies the scheme to the document and its controls.
How do I capture both variants for visual regression tests?
Render once with a light preference and once with a dark preference, then compare component screenshots and inspect images, charts and embedded content separately.


