Dark mode has gone from a developer preference to a mainstream expectation. Operating systems, browsers, and native apps all support dark color schemes, and users notice when a website does not. Beyond aesthetics, dark mode reduces eye strain in low-light environments, extends battery life on OLED displays by lighting fewer pixels, and gives users control over their reading experience.
This guide covers dark mode implementation from the CSS foundation through JavaScript toggle logic, preference persistence, and the edge cases that trip up most implementations.
The CSS Custom Properties Foundation
Every robust dark mode implementation starts with CSS custom properties (variables). Instead of hardcoding color values throughout your stylesheets, you define a set of semantic color tokens on the root element and reference them everywhere else. Switching themes becomes a matter of redefining those tokens.
:root {
/* Light theme (default) */
--color-bg: #f9f8f6;
--color-bg-secondary: #f5f3f0;
--color-surface: #ffffff;
--color-text: #292524;
--color-text-secondary: #78716c;
--color-heading: #1c1917;
--color-border: #e7e5e4;
--color-accent: #c2724e;
--color-accent-hover: #a85d3a;
--color-link: #c2724e;
--color-code-bg: #f5f3f0;
--color-shadow: rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] {
--color-bg: #1c1917;
--color-bg-secondary: #292524;
--color-surface: #292524;
--color-text: #d6d3d1;
--color-text-secondary: #a8a29e;
--color-heading: #fafaf9;
--color-border: #44403c;
--color-accent: #d6a88a;
--color-accent-hover: #e8c4aa;
--color-link: #d6a88a;
--color-code-bg: #292524;
--color-shadow: rgba(0, 0, 0, 0.3);
}
Using a data-theme attribute on the root element gives you explicit control over theme switching via JavaScript, while keeping the CSS clean and selector-specificity low.
Applying the Tokens
Once your tokens are defined, every color reference in your stylesheets uses var() instead of a hardcoded value:
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: 'IBM Plex Sans', system-ui, sans-serif;
line-height: 1.7;
}
h1, h2, h3, h4 {
color: var(--color-heading);
}
a {
color: var(--color-link);
text-decoration-color: var(--color-border);
transition: color 0.2s ease;
}
a:hover {
color: var(--color-accent-hover);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px var(--color-shadow);
}
pre, code {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
}
.text-secondary {
color: var(--color-text-secondary);
}
This approach means your entire color system can be swapped by changing the values on a single selector. No duplicate stylesheets, no complex class overrides — one token definition controls the entire theme. This pattern connects directly to modern CSS features like cascade layers and custom property scoping.
Respecting System Preferences
The prefers-color-scheme media query detects the user’s operating system theme preference. Respecting this preference is the baseline — users who set their OS to dark mode expect websites to follow suit automatically:
/* Apply dark theme when system preference is dark
AND no explicit theme has been chosen */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #1c1917;
--color-bg-secondary: #292524;
--color-surface: #292524;
--color-text: #d6d3d1;
--color-text-secondary: #a8a29e;
--color-heading: #fafaf9;
--color-border: #44403c;
--color-accent: #d6a88a;
--color-accent-hover: #e8c4aa;
--color-link: #d6a88a;
--color-code-bg: #292524;
--color-shadow: rgba(0, 0, 0, 0.3);
}
}
The selector :root:not([data-theme="light"]) is key. It applies dark mode for users with a dark OS preference, but only when they have not explicitly selected light mode on your site. This creates a three-state system: system preference (auto), explicit light, and explicit dark.
JavaScript Theme Toggle
The toggle component needs to handle three responsibilities: detecting the initial theme, switching themes on user interaction, and persisting the choice across sessions.
class ThemeToggle {
constructor() {
this.html = document.documentElement;
this.toggle = document.querySelector('.theme-toggle');
this.storageKey = 'theme-preference';
// Determine initial theme
this.currentTheme = this.getInitialTheme();
this.applyTheme(this.currentTheme);
// Listen for toggle clicks
this.toggle?.addEventListener('click', () => this.switchTheme());
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem(this.storageKey)) {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
getInitialTheme() {
// 1. Check saved preference
const saved = localStorage.getItem(this.storageKey);
if (saved) return saved;
// 2. Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// 3. Default to light
return 'light';
}
applyTheme(theme) {
this.currentTheme = theme;
this.html.dataset.theme = theme;
this.updateToggleButton(theme);
this.updateMetaThemeColor(theme);
}
switchTheme() {
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
localStorage.setItem(this.storageKey, newTheme);
}
updateToggleButton(theme) {
if (!this.toggle) return;
this.toggle.setAttribute('aria-label',
`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`
);
}
updateMetaThemeColor(theme) {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.content = theme === 'dark' ? '#1c1917' : '#f9f8f6';
}
}
}
document.addEventListener('DOMContentLoaded', () => new ThemeToggle());
Preventing the Flash of Wrong Theme
The most common dark mode bug is a flash of the wrong theme on page load — the page renders in light mode for a split second before JavaScript applies the dark theme. This happens because CSS and HTML render before JavaScript executes.
The fix is a small inline script in the <head> that runs before any rendering occurs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="theme-color" content="#f9f8f6">
<!-- This runs before any paint — no flash -->
<script>
(function() {
var saved = localStorage.getItem('theme-preference');
var theme = saved || (
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light'
);
document.documentElement.dataset.theme = theme;
if (theme === 'dark') {
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', '#1c1917');
}
})();
</script>
<link rel="stylesheet" href="/style.css">
</head>
This script must be inline (not an external file) and must appear before your stylesheet link. It reads localStorage synchronously, sets the data attribute, and the browser applies the correct theme tokens from the very first paint. No flash, no layout shift.
Building the Toggle Button
The toggle button needs clear visual feedback and proper accessibility. A common pattern uses sun and moon icons:
<button class="theme-toggle" type="button" aria-label="Switch to dark mode">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.5rem;
cursor: pointer;
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.theme-toggle:hover {
background: var(--color-bg-secondary);
border-color: var(--color-text-secondary);
}
/* Show sun in dark mode, moon in light mode */
[data-theme="dark"] .icon-sun { display: block; }
[data-theme="dark"] .icon-moon { display: none; }
[data-theme="light"] .icon-sun,
:root:not([data-theme]) .icon-sun { display: none; }
[data-theme="light"] .icon-moon,
:root:not([data-theme]) .icon-moon { display: block; }
Handling Images and Media
Images designed for light backgrounds often look jarring against dark backgrounds. Several techniques address this without requiring separate image assets:
/* Reduce brightness of images in dark mode */
[data-theme="dark"] img:not(.logo):not(.avatar) {
filter: brightness(0.9) contrast(1.05);
}
/* Use the picture element for light/dark image variants */
<picture>
<source srcset="/images/diagram-dark.png" media="(prefers-color-scheme: dark)">
<img src="/images/diagram-light.png" alt="Architecture diagram">
</picture>
/* SVG illustrations can inherit theme colors */
.illustration svg {
fill: var(--color-text-secondary);
stroke: var(--color-border);
}
/* Invert specific images that are purely decorative */
[data-theme="dark"] .decorative-pattern {
filter: invert(1) hue-rotate(180deg);
opacity: 0.6;
}
/* Handle transparent PNGs with dark content */
[data-theme="dark"] .logo-dark-content {
filter: brightness(0) invert(1);
}
Shadows, Borders, and Depth
Light mode uses shadows to create depth — cards float above the background. Dark mode shadows are barely visible against dark backgrounds, so the depth model needs adjustment:
:root {
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
/* Increase shadow opacity and use borders for depth */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5);
}
/* Use lighter borders in dark mode to create depth hierarchy */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}
/* In dark mode, layered surfaces use progressively lighter backgrounds */
[data-theme="dark"] .modal-overlay { background: rgba(0, 0, 0, 0.6); }
[data-theme="dark"] .modal {
background: #292524; /* base surface */
border: 1px solid #44403c;
}
[data-theme="dark"] .modal .dropdown {
background: #3a3632; /* elevated surface — slightly lighter */
border: 1px solid #57534e;
}
The principle in dark mode: higher elevation equals lighter background. This is the opposite of light mode, where higher elevation means a more prominent shadow. Following this principle creates a natural depth hierarchy that users perceive intuitively.
Form Elements and Interactive Components
Form inputs, selects, and textareas need explicit dark mode styling. Browser defaults render light-colored form controls that clash with dark backgrounds:
input, textarea, select {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
input:focus, textarea:focus, select:focus {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
/* Color scheme hint tells the browser to style scrollbars and
form controls in dark mode */
[data-theme="dark"] {
color-scheme: dark;
}
[data-theme="light"] {
color-scheme: light;
}
/* Placeholder text */
::placeholder {
color: var(--color-text-secondary);
opacity: 0.7;
}
The color-scheme property is important — it tells the browser to render native UI elements (scrollbars, form controls, date pickers) in the appropriate color scheme. Without it, you get dark-themed content with bright white scrollbars and date picker popups.
Smooth Theme Transition
Adding a transition to the theme switch creates a polished experience. Apply it to the root element when toggling, then remove it to avoid unexpected transitions during normal interaction:
/* Only add transition when the theme is actively switching */
html.theme-transitioning,
html.theme-transitioning *,
html.theme-transitioning *::before,
html.theme-transitioning *::after {
transition: background-color 0.3s ease,
color 0.2s ease,
border-color 0.3s ease,
box-shadow 0.3s ease !important;
}
// In the switchTheme method:
switchTheme() {
// Add transition class
this.html.classList.add('theme-transitioning');
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
localStorage.setItem(this.storageKey, newTheme);
// Remove transition class after animation completes
setTimeout(() => {
this.html.classList.remove('theme-transitioning');
}, 350);
}
This approach avoids having permanent color transitions on every element, which would cause colors to animate during unrelated state changes (hover effects, focus states, dynamic content updates).
Testing Dark Mode
Testing dark mode requires checking more than just background colors. A thorough audit covers these areas:
- Contrast ratios — Every text-background combination must meet WCAG 2.1 AA standards (4.5:1 for body text, 3:1 for large text). Use browser DevTools’ color contrast checker
- Hardcoded colors — Search your CSS for any hex value, rgb(), or hsl() that does not use a custom property. Every hardcoded color is a dark mode bug waiting to happen
- Third-party embeds — Code syntax highlighters, embedded maps, video players, and ad units may not respect your theme. Style their containers to minimize visual clash
- Box shadows — Verify that shadows still create visible depth in dark mode. Invisible shadows make cards look flat
- Transparent images — PNG images with transparency designed for white backgrounds may become unreadable on dark backgrounds
- Focus indicators — Keyboard focus outlines must remain visible against both light and dark backgrounds
# Find hardcoded color values in your CSS files
grep -rn '#[0-9a-fA-F]\{3,8\}' src/styles/ --include="*.css" | grep -v 'var('
grep -rn 'rgb\|rgba\|hsl' src/styles/ --include="*.css" | grep -v 'var('
Dark Mode in Frameworks
If you are using a CSS framework, dark mode integration varies. Tailwind CSS supports dark mode natively with the dark: variant prefix. Modern frameworks like Next.js and Nuxt can handle the flash-prevention script through their document head management:
<!-- Tailwind CSS dark mode -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-50">
Dashboard
</h1>
<p class="text-gray-600 dark:text-gray-400">
Welcome back
</p>
</div>
// tailwind.config.js
module.exports = {
darkMode: 'selector', // uses [data-theme="dark"] or .dark class
// or
darkMode: 'media', // uses prefers-color-scheme only
}
For projects using CSS custom properties (the approach covered in this guide), any framework works without modification. The token-based approach is framework-agnostic and works with plain CSS, Tailwind, CSS Modules, or styled-components. This aligns with responsive design principles where the CSS layer handles visual presentation independently of the framework layer.
Server-Side Rendering Considerations
When your site uses server-side rendering (Next.js, Nuxt, Astro), the server does not know the user’s theme preference during the initial render. The flash-prevention script handles the client side, but the server must send markup that works before JavaScript executes:
// Next.js: app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{
__html: `(function(){
var t=localStorage.getItem('theme-preference');
var d=t||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
document.documentElement.dataset.theme=d;
})()`
}} />
</head>
<body>{children}</body>
</html>
);
}
The suppressHydrationWarning attribute prevents React from warning about the mismatch between server-rendered HTML (no data-theme) and client-hydrated HTML (data-theme set by the inline script).
Storing Preferences: localStorage vs Cookies
localStorage is the simplest option for persisting theme preference, but cookies offer advantages for server-rendered applications:
- localStorage — Client-only, works for static sites and SPAs. The inline script reads it synchronously before paint. Does not work for server-side theme rendering
- Cookies — Sent with every HTTP request, allowing the server to render the correct theme from the first response. Eliminates the flash entirely for server-rendered pages. Use
SameSite=Lax,Secure, and a reasonableMax-Age
// Setting a theme cookie
function setThemeCookie(theme) {
const maxAge = 365 * 24 * 60 * 60; // 1 year
document.cookie = `theme=${theme};path=/;max-age=${maxAge};SameSite=Lax`;
}
// Reading the cookie server-side (Node.js example)
function getThemeFromCookies(cookieHeader) {
const match = cookieHeader?.match(/theme=(light|dark)/);
return match ? match[1] : null;
}
Frequently Asked Questions
Should dark mode be the default theme?
No. Default to the user’s system preference using prefers-color-scheme, with light as the fallback when no system preference is detected. Most users have a system-level preference set, and respecting it provides the best initial experience. The toggle exists for users who want a different theme on your specific site.
How do I handle syntax highlighting in code blocks for dark mode?
Most syntax highlighting libraries (Prism, Shiki, Highlight.js) offer separate light and dark themes. Load both themes and toggle between them using the same data-theme attribute. Shiki can generate dual-theme HTML at build time, embedding both color sets as CSS custom properties, so no runtime theme switching is needed for code blocks.
Does dark mode affect SEO or page performance?
Dark mode has no direct impact on SEO. For performance, the CSS custom properties approach adds negligible overhead. The inline flash-prevention script is typically under 200 bytes. The only performance concern is if you load two separate stylesheets (one per theme) instead of using CSS custom properties — always prefer the token-based approach to avoid doubling your CSS file size.
How do I handle user-generated content that includes inline styles?
User-generated content with inline styles (style="color: black") will break in dark mode because inline styles override custom properties. The cleanest solution is to strip inline color styles during content sanitization on the server. If that is not possible, use a CSS !important override scoped to the content container, or apply filter: invert(1) hue-rotate(180deg) to the content area in dark mode as a last resort.