How to Implement Dark Mode on a Web Page
Implement accessible dark mode with CSS tokens, system preferences, a saved toggle, browser controls, testing, and practical troubleshooting.
Use CSS design tokens and prefers-color-scheme to make a page follow a visitor’s operating-system preference. Add color-scheme: light dark so browser-rendered controls can adapt, then add a JavaScript override only when visitors need a site-level choice.
1. Start with a CSS-first dark-mode implementation
prefers-color-scheme exposes the user agent or operating-system preference for a light or dark presentation; it does not create a complete dark design for you. Define both foreground and background values as semantic tokens, then switch those tokens at the root.
:root {
color-scheme: light dark;
--page-bg: #ffffff;
--page-fg: #202124;
--surface: #f3f4f6;
--surface-raised: #ffffff;
--border: #d7d9dd;
--muted: #5f6368;
--accent: #1558a6;
--focus: #0b57d0;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #17191c;
--page-fg: #f2f3f5;
--surface: #24272b;
--surface-raised: #2e3238;
--border: #4a4f57;
--muted: #b7bcc5;
--accent: #9fc5ff;
--focus: #a8c7fa;
}
}
* {
box-sizing: border-box;
}
html {
background: var(--page-bg);
}
body {
margin: 0;
color: var(--page-fg);
background: var(--page-bg);
font: 16px/1.5 system-ui, sans-serif;
}
.card {
padding: 1rem;
color: var(--page-fg);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.hero {
background: var(--surface-raised);
}
a {
color: var(--accent);
}
button,
input,
select,
textarea {
color: var(--page-fg);
background: var(--surface-raised);
border: 1px solid var(--border);
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
The color values above are illustrative. Check the actual text sizes, fonts, links, disabled states, borders, focus indicators, illustrations, and component states in both palettes. “Dark” does not require black and white, and the scheme name does not guarantee readable contrast. The MDN prefers-color-scheme reference documents the media feature, while the W3C CSS Color Adjustment specification explains what color-scheme declarations do and do not guarantee.
Tell the browser about supported schemes early
Put this declaration in the document head, before stylesheet links where possible:
<meta name="color-scheme" content="light dark">
It helps the user agent choose an initial treatment for browser-controlled UI such as form controls and scrollbars. The MDN color-scheme reference recommends placing the meta tag before CSS style information to reduce unwanted flashes.
2. Build a complete theme token system
Use semantic names such as --page-bg, --text-primary, and --border-subtle instead of names tied to a hue. Components then consume the same tokens in either mode.
:root {
--text-primary: #202124;
--text-secondary: #5f6368;
--link: #1558a6;
--code-bg: #f6f8fa;
--success: #137333;
--warning: #b06000;
--danger: #b3261e;
}
@media (prefers-color-scheme: dark) {
:root {
--text-primary: #f2f3f5;
--text-secondary: #b7bcc5;
--link: #9fc5ff;
--code-bg: #202327;
--success: #81c995;
--warning: #f6bd60;
--danger: #ffb4ab;
}
}
body { color: var(--text-primary); }
small, .muted { color: var(--text-secondary); }
pre, code { background: var(--code-bg); }
.status-success { color: var(--success); }
Do not rely on inherited text color alone. Set the background and foreground together for every surface, including modals, cards, tables, code blocks, tooltips, and empty states.
3. Add a light/dark/system toggle
A CSS-only implementation is enough when the page should always follow the device setting. Add a manual control when visitors need to choose a theme that differs from their system preference. A robust three-state model is system, light, and dark.
Markup
<label for="theme-select">Theme</label>
<select id="theme-select">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
CSS override rules
/* Keep the media-query palette as the no-JavaScript fallback. */
:root[data-theme="light"] {
color-scheme: light;
--page-bg: #ffffff;
--page-fg: #202124;
--surface: #f3f4f6;
--surface-raised: #ffffff;
--border: #d7d9dd;
--accent: #1558a6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--page-bg: #17191c;
--page-fg: #f2f3f5;
--surface: #24272b;
--surface-raised: #2e3238;
--border: #4a4f57;
--accent: #9fc5ff;
}
Apply the saved choice before the page paints
Run a small inline script in the head before your main stylesheet or application bundle. It reads only the expected values and leaves the root untagged for the system default.
<script>
(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.dataset.theme = saved;
}
})();
</script>
Persist changes and follow system changes
const select = document.querySelector('#theme-select');
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
function applyTheme(value) {
if (value === 'light' || value === 'dark') {
root.dataset.theme = value;
} else {
delete root.dataset.theme;
}
select.value = value;
}
const saved = localStorage.getItem('theme');
applyTheme(saved === 'light' || saved === 'dark' ? saved : 'system');
select.addEventListener('change', () => {
const value = select.value;
if (value === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', value);
}
applyTheme(value);
});
media.addEventListener('change', () => {
if (!localStorage.getItem('theme')) {
applyTheme('system');
}
});
The system listener matters only when JavaScript reads the preference. If a visitor has chosen light or dark, do not overwrite that deliberate choice when the operating system changes. Chrome’s modern web guidance recommends retaining the CSS system default when JavaScript fails, applying saved settings early, and listening for preference changes when scripts use matchMedia.
4. Optional: use light-dark()
The light-dark() function expresses a pair of colors directly when an active color scheme has been set:
:root {
color-scheme: light dark;
--page-bg: light-dark(#ffffff, #17191c);
--page-fg: light-dark(#202124, #f2f3f5);
--accent: light-dark(#1558a6, #9fc5ff);
}
body {
color: var(--page-fg);
background: var(--page-bg);
}
Keep the media-query version as a fallback when older browser support matters. The Chrome guidance lists light-dark() as newly available from May 13, 2024; verify support against the browser versions your project actually targets.
5. Handle controls, media, SVG, and embedded content
- Form controls: set
color-schemeand explicit foreground/background tokens so inputs, selects, and buttons remain readable. - Images: provide dark-friendly assets or a border/background around transparent images that disappear against the dark surface.
- SVG: use
currentColorfor icons that should follow text, and test standalone SVG documents separately. - iframes: coordinate the theme with the embedded application when you control both sides. MDN documents parent-scheme behavior for applicable embedded SVG and iframe contexts; Chrome’s guidance notes that Safari resolves the preference to the system setting inside an iframe, so an explicit theme parameter or message may be required.
- Canvas and charts: redraw when the theme changes; CSS cannot recolor pixels already painted into a canvas.
- Print: decide whether print output should always use a light palette and add print-specific rules if needed.
6. Validate readability and interaction states
Review the complete experience in light and dark modes. A practical checklist:
- Page background, header, navigation, cards, dialogs, tables, and code blocks have intentional colors.
- Body text, secondary text, links, visited links, placeholders, and disabled text remain legible.
- Keyboard focus is visible against both backgrounds.
- Hover, active, selected, checked, error, warning, and success states are distinguishable without relying on color alone.
- Images, logos, syntax highlighting, charts, and third-party widgets do not retain an unreadable light-only treatment.
- Reduced-motion preferences and theme transitions do not create distracting flashes.
The W3C specification cautions that system colors combined with author colors cannot guarantee a particular contrast level. Choose foreground and background together and validate the actual typography and states used by your site. The WAI C23 technique is an example of allowing user-agent preferences to influence colors, not a substitute for checking your own interface.
7. Compatibility notes
| Feature | Use | Practical note |
|---|---|---|
prefers-color-scheme |
Detect light/dark preference in CSS or JavaScript | MDN describes it as widely available across many devices and browser versions since January 2020; verify your target browsers. |
color-scheme |
Opt browser UI into supported schemes | Chrome guidance lists broad availability since February 3, 2022. |
light-dark() |
Write paired color values compactly | Keep a media-query fallback for older browsers; Chrome guidance lists availability from May 13, 2024. |
8. Troubleshooting dark mode
The page stays light in dark OS mode
Check that the media query is spelled @media (prefers-color-scheme: dark), that the tokens are used by the components, and that a later selector is not overriding them. Inspect the computed background-color and color values in browser developer tools.
Only some components switch
Hard-coded colors are usually the cause. Replace component-specific hex values with semantic tokens, including borders, icons, placeholders, and nested surfaces.
A flash of the wrong theme appears
Put the color-scheme meta tag early and run the saved-theme script in the document head before the main stylesheet or application renders. Avoid waiting for a client-side hydration pass to set the root attribute.
The toggle resets after refresh
Confirm that localStorage.setItem and localStorage.removeItem use the same key, and that the code handles storage-disabled contexts without preventing the CSS system fallback.
System changes are ignored
When using JavaScript, create a matchMedia('(prefers-color-scheme: dark)') object and register its change listener. Do not apply the listener’s result over a saved light or dark override.
Native controls look wrong
Declare color-scheme: light dark on the root and set explicit colors on controls where your design requires them. Test select menus and date inputs in each target browser.
An iframe has the opposite theme
The embedded document may be resolving its own system preference. If you own both applications, pass the selected theme through a URL parameter, postMessage, or shared application state, then test Safari separately.
9. Capture and review both themes
Automated screenshots help catch regressions in surfaces, focus states, and embedded content. Capture the same route with a light and dark preference, then compare the output in CI or during review. For a browser-based DIY workflow, set the emulated color scheme in your automation tool before navigation and wait for fonts, images, and asynchronous content to finish loading.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF, and its dark-mode option can capture a page in the scheme you need without maintaining browser infrastructure. See the ScreenshotNeo API documentation for the complete parameter list.
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}`);
Pass the dark-mode option from the API docs alongside your URL when you need a dark capture. Before capture, cookie and consent banners, newsletter popups, and chat widgets are removed. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. The MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. 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 to capture your light and dark themes.
FAQ
Does prefers-color-scheme change my site’s colors automatically?
No. It reports a preference. Your CSS must define the palettes and apply them to the page.
Do I need JavaScript for dark mode?
No. CSS is sufficient when the site should follow the system setting. JavaScript is needed for a persistent manual override or a theme control.
Should the default be light or dark?
Use the visitor’s system preference as the default unless your product has a documented reason to choose otherwise.
Can I guarantee accessibility by using color-scheme?
No. It tells the browser which schemes your page supports; you still need to validate contrast and interaction states for your actual design.


