How to Add Night Mode to Websites
Add automatic dark mode with CSS, then add a persistent system/light/dark toggle without flashes or unreadable controls.
The short answer: declare light and dark support in the document head, set coordinated color tokens, and switch them with prefers-color-scheme. Add a manual control only when users need to pin a theme, save that choice in localStorage, and keep the operating-system preference as the fallback.
The browser APIs involved are prefers-color-scheme, which detects the requested system or user-agent theme, and color-scheme, which lets browser-provided UI such as form controls and scrollbars adapt. Your own components still need explicit colors. See the Google Chrome dark-mode guide, MDN’s prefers-color-scheme reference, and MDN’s color-scheme reference.
1. Add automatic night mode with CSS
Put the meta element before your styles so the browser knows the page supports both schemes before the first paint.
<!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'>
<title>Night mode example</title>
<link rel='stylesheet' href='/styles.css'>
</head>
<body>
<main class='page'>
<article class='card'>
<h1>Readable in every scheme</h1>
<p>The palette follows the visitor's system preference.</p>
<a href='/docs'>Read the documentation</a>
</article>
</main>
</body>
</html>
:root {
color-scheme: light dark;
--page-bg: #ffffff;
--page-text: #202124;
--surface: #f3f4f6;
--border: #d1d5db;
--link: #0645ad;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #171717;
--page-text: #f2f2f2;
--surface: #292929;
--border: #4b5563;
--link: #8ab4f8;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--page-bg);
color: var(--page-text);
font: 1rem/1.6 system-ui, sans-serif;
}
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.card {
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
}
a {
color: var(--link);
}
Keep foreground and background values together in tokens. A dark page with an unchanged dark border, muted text, image, or focus ring is only partially themed.
What each declaration does
<meta name='color-scheme' content='light dark'>advertises supported schemes early.color-scheme: light darkallows native controls, scrollbars, and other browser UI to use a suitable scheme.@media (prefers-color-scheme: dark)changes author-controlled colors when the visitor prefers dark mode.- Custom properties give every component the same palette and make later redesigns manageable.
2. Add a persistent light, dark, and system choice
Automatic mode is enough for many sites. If users need a manual override, use three explicit states: system, light, and dark. A saved light or dark choice stays pinned when the operating-system setting changes; system follows the OS.
Run this small script in the head before deferred application code. It applies a saved choice before the first paint, reducing a flash of the wrong theme.
<script>
(() => {
const saved = localStorage.getItem('theme');
const root = document.documentElement;
const meta = document.querySelector('meta[name="color-scheme"]');
if (saved === 'light' || saved === 'dark') {
root.dataset.theme = saved;
if (meta) meta.content = saved;
} else {
delete root.dataset.theme;
if (meta) meta.content = 'light dark';
}
})();
</script>
Place the control in the body:
<label for='theme-select'>Color theme</label>
<select id='theme-select'>
<option value='system'>System</option>
<option value='light'>Light</option>
<option value='dark'>Dark</option>
</select>
Override the media-query defaults with a data attribute:
:root[data-theme='light'] {
color-scheme: light;
--page-bg: #ffffff;
--page-text: #202124;
--surface: #f3f4f6;
--border: #d1d5db;
--link: #0645ad;
}
:root[data-theme='dark'] {
color-scheme: dark;
--page-bg: #171717;
--page-text: #f2f2f2;
--surface: #292929;
--border: #4b5563;
--link: #8ab4f8;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--page-bg: #171717;
--page-text: #f2f2f2;
--surface: #292929;
--border: #4b5563;
--link: #8ab4f8;
}
}
Wire the select to storage and update the meta element:
const select = document.querySelector('#theme-select');
const root = document.documentElement;
const meta = document.querySelector('meta[name="color-scheme"]');
const saved = localStorage.getItem('theme');
select.value = saved === 'light' || saved === 'dark' ? saved : 'system';
select.addEventListener('change', () => {
const choice = select.value;
if (choice === 'system') {
localStorage.removeItem('theme');
delete root.dataset.theme;
if (meta) meta.content = 'light dark';
return;
}
localStorage.setItem('theme', choice);
root.dataset.theme = choice;
if (meta) meta.content = choice;
});
Follow later operating-system changes
CSS updates automatically when the system preference changes. JavaScript that reads the preference must listen for changes if it updates labels, icons, or other behavior.
const media = window.matchMedia('(prefers-color-scheme: dark)');
function updateSystemLabel(event) {
const systemIsDark = event.matches;
const pinned = localStorage.getItem('theme');
// A pinned choice remains pinned. Update only UI that describes system mode.
if (!pinned) {
document.querySelector('#system-status').textContent =
systemIsDark ? 'System (dark)' : 'System (light)';
}
}
updateSystemLabel(media);
media.addEventListener('change', updateSystemLabel);
Do not overwrite a pinned choice inside that listener. The system event is a fallback update, not permission to discard the user’s explicit selection.
3. Theme every surface and interaction state
Audit more than the page background and body text:
- Cards, navigation, dialogs, tables, code blocks, and tooltips.
- Links in default, visited, hover, active, and focus states.
- Inputs, selects, checkboxes, validation messages, disabled controls, and placeholders.
- Visible keyboard focus indicators with enough contrast against both surfaces.
- Borders, dividers, shadows, SVG fills, charts, and syntax highlighting.
- Images and illustrations whose transparent or white backgrounds look wrong in dark mode.
- Embedded documents and iframes that have their own theme behavior.
Browser defaults mixed with authored colors do not guarantee a readable contrast level. The W3C CSS Color Adjustment specification explains why explicit foreground/background pairs are needed.
Use light-dark() when appropriate
Modern CSS can express a light and dark value directly:
:root {
color-scheme: light dark;
--page-bg: light-dark(#fff, #171717);
--page-text: light-dark(#202124, #f2f2f2);
}
body {
background: var(--page-bg);
color: var(--page-text);
}
Keep the custom-property and media-query version as a fallback when your browser support target does not include light-dark(). A manual data-attribute override can still define explicit values above the fallback.
4. Tailwind CSS implementation
Tailwind’s dark variant defaults to the prefers-color-scheme media feature. Its dark-mode documentation also describes configuring a selector for manual switching.
<!-- Automatic system preference -->
<div class='bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100'>
Content
</div>
For a stored manual theme, configure a selector such as [data-theme=dark], set that attribute on <html>, and use the same early initialization script shown above. The browser concepts and precedence rules remain the same regardless of framework.
5. Test both schemes before shipping
- Open browser DevTools and emulate
prefers-color-scheme: light. - Repeat with
prefers-color-scheme: dark. Microsoft Edge documents this in its preferred-color-scheme simulation guide. - Test a fresh browser profile with no saved preference.
- Save light and dark choices, reload, and confirm they persist.
- Choose system, change the OS theme, and confirm the page follows it.
- Tab through every control in both palettes and inspect focus indicators.
- Check images, charts, code samples, validation states, and embedded content.
- Turn off JavaScript and confirm the automatic CSS theme still works.
Testing checklist
- Readable body, muted, link, and disabled text.
- Visible borders and separators without excessive brightness.
- Inputs and native controls match the selected scheme.
- Hover, active, focus, error, success, and loading states remain distinct.
- No flash caused by applying a saved theme after the first paint.
- No hard-coded white or black values hidden in component styles.
6. Troubleshooting common problems
| Symptom | Cause | Fix |
|---|---|---|
| Nothing changes in dark mode | The page has no prefers-color-scheme rule, or components use fixed colors. |
Move colors into custom properties and override them inside the dark media query. |
| Native controls stay bright | color-scheme was not declared on the root or meta element. |
Set both <meta name='color-scheme' content='light dark'> and color-scheme: light dark. |
| Theme flashes on reload | The saved preference is applied after the first paint. | Run the small storage-reading script inline in the head before deferred scripts and styles that depend on it. |
| OS changes unexpectedly undo a manual choice | The matchMedia change handler always applies the system result. |
Check for a saved pinned choice and update only the system fallback when one exists. |
| Text or borders disappear | Foreground, border, or muted tokens were not changed with the background. | Audit every semantic token in both palettes, including focus and disabled states. |
| Toggle resets between tabs | State is held only in memory. | Persist light/dark in localStorage; use the system state when the key is absent. |
| Server-rendered markup disagrees with the browser | The server cannot read browser localStorage. |
Render a neutral structure, apply the early client script, or persist the preference in a cookie that the server can read. |
| Images look washed out or glaring | Image assets were designed only for a light background. | Provide dark-compatible assets, transparent artwork, or a themed container; do not blindly invert photographs. |
7. Performance, reliability, and cost
Theme switching is normally inexpensive: CSS media queries and custom properties avoid rerendering the page. Keep the initialization script small, avoid layout reads while applying the theme, and do not animate large color changes unless the motion is intentional. Respect prefers-reduced-motion if you animate a transition.
The main reliability risk is inconsistent precedence. Define one order and document it: an explicit light or dark choice wins, otherwise the system preference wins, otherwise the light defaults apply. Test a new visitor, a returning visitor, a changed OS preference, private browsing behavior, and storage failures.
Night mode itself has no service cost. You need HTML, CSS, optional JavaScript, and browser developer tools; no special API or paid product is required.
Or skip the browser setup
If you need screenshots of a dark-mode page for documentation, visual regression, or social previews, ScreenshotNeo can capture a URL after the page applies its theme. Use the same URL you would open in a browser and pass your theme state through the page’s own query parameter, cookie, or script.
See the ScreenshotNeo documentation for the full option list.
curl -G 'https://api.screenshotneo.com/v1/shot' \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com/night-mode \
-o shot.webp
import requests
r = requests.get(
'https://api.screenshotneo.com/v1/shot',
params={
'access_key': 'YOUR_API_KEY',
'url': 'https://example.com/night-mode',
},
timeout=90,
)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/night-mode'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await Bun.write('shot.webp', data);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. ScreenshotNeo also provides an MCP server so Claude, Cursor, and other MCP clients can take screenshots, inspect pages, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account.
FAQ
Does dark mode require JavaScript?
No. prefers-color-scheme, custom properties, and color-scheme provide automatic support with HTML and CSS. JavaScript is only needed for a manual override or theme-dependent behavior.
Should the default be light or dark?
Use your product’s design decision, but support the preference the visitor has already expressed. With light dark, the browser can select the requested scheme when no explicit choice is saved.
Can I force dark mode for every visitor?
You can, but it ignores a user preference. A system fallback with an explicit override is usually easier to understand and maintain.
Does color-scheme recolor my whole site?
No. It affects browser-provided UI. Your authored backgrounds, text, borders, images, and components still need theme rules.
How do I support a system option without storing the word “system”?
Remove the saved key when the user selects system. The absence of a pinned value naturally falls back to prefers-color-scheme.


