Published March 27, 2026 · 16 min read
Dark mode is no longer optional. Over 80% of smartphone users enable dark mode, and all major operating systems now support system-wide dark themes. If your website does not support dark mode, you are delivering a worse experience to the majority of your visitors.
This guide covers everything from the simplest implementation to production-ready dark mode with smooth transitions, user preferences, and accessibility compliance. Every code example is copy-paste ready. You are reading this on a dark-mode site right now — every technique described here is used in production across our 220+ sites.
The prefers-color-scheme media query detects the user's system-level color preference. This is the foundation of every dark mode implementation.
/* Light mode (default) */
body {
background: #ffffff;
color: #1a1a1a;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #0a0a0a;
color: #e8e8e8;
}
}
This is the simplest possible implementation. When a user's OS is set to dark mode, the dark styles apply automatically. No JavaScript required. No toggle button. It just works.
Browser support for prefers-color-scheme is universal in 2026. Every modern browser on every platform supports it. There is no reason not to use it.
Hardcoding colors in every media query does not scale. The professional approach uses CSS custom properties (variables) that change based on the color scheme.
:root {
/* Light mode colors (default) */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #eeeeee;
--text-primary: #1a1a1a;
--text-secondary: #555555;
--text-muted: #888888;
--accent: #ff5f1f;
--accent-hover: #ff8844;
--border: #e0e0e0;
--shadow: rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-tertiary: #1a1a1a;
--text-primary: #e8e8e8;
--text-secondary: #cccccc;
--text-muted: #777777;
--accent: #ff5f1f;
--accent-hover: #ff8844;
--border: #222222;
--shadow: rgba(0, 0, 0, 0.4);
}
}
/* Use variables everywhere */
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 2px 8px var(--shadow);
}
a {
color: var(--accent);
}
a:hover {
color: var(--accent-hover);
}
Notice that the accent color stays the same in both modes. Not every color needs to change. Orange on dark backgrounds and orange on light backgrounds both provide good contrast. Test each color individually.
System-level preferences are a good default, but users should be able to override them. Here is a complete toggle implementation:
/* HTML */ <button id="theme-toggle" aria-label="Toggle dark mode"> <span class="light-icon">☀️</span> <span class="dark-icon">🌙</span> </button>
/* CSS - add a data attribute selector */
[data-theme="dark"] {
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-tertiary: #1a1a1a;
--text-primary: #e8e8e8;
--text-secondary: #cccccc;
--text-muted: #777777;
--border: #222222;
--shadow: rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .light-icon { display: none; }
[data-theme="dark"] .dark-icon { display: inline; }
[data-theme="light"] .light-icon { display: inline; }
[data-theme="light"] .dark-icon { display: none; }
/* JavaScript */
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Check for saved preference, then system preference
function getTheme() {
const saved = localStorage.getItem('theme');
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
// Apply theme
function setTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
// Initialize
setTheme(getTheme());
// Toggle
toggle.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
setTheme(current === 'dark' ? 'light' : 'dark');
});
// Listen for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
This gives you three levels of control: system default, user override (saved in localStorage), and real-time response to system changes. The user's manual choice is respected over the system preference.
Without transitions, theme switching feels jarring. Add a transition to the root element:
:root {
transition: background-color 0.3s ease,
color 0.3s ease;
}
* {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
/* Better: Targeted transitions */
body, .card, .nav, .footer, .sidebar, input, textarea {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
Dark mode is not just "invert everything." Pure black (#000000) backgrounds with pure white (#ffffff) text creates too much contrast and causes eye strain. Here are proven color pairings:
| Element | Dark Mode | Light Mode | Notes |
|---|---|---|---|
| Background | #0a0a0a | #ffffff | Near-black, not pure black |
| Surface | #111111 | #f5f5f5 | Cards, modals, elevated surfaces |
| Text primary | #e8e8e8 | #1a1a1a | Near-white, not pure white |
| Text secondary | #aaaaaa | #555555 | Descriptions, captions |
| Border | #222222 | #e0e0e0 | Subtle separation |
| Accent | #ff5f1f | #ff5f1f | Same color works in both modes |
The key principle: in dark mode, convey hierarchy through elevation (lighter surfaces = more prominent) instead of shadows. Material Design calls this "light on dark" elevation. A card background of #111 on a #0a0a0a page background creates the same visual hierarchy as a white card with a shadow on a gray background.
WCAG 2.1 requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text. Our color scheme above provides:
Test your color combinations with our Contrast Checker tool before deploying.
Images designed for light backgrounds can look harsh on dark backgrounds. Here are strategies to handle this:
/* Slightly reduce image brightness in dark mode */
@media (prefers-color-scheme: dark) {
img:not([src*=".svg"]) {
filter: brightness(0.9);
}
}
/* Invert dark-on-light diagrams */
@media (prefers-color-scheme: dark) {
.diagram, .chart {
filter: invert(1) hue-rotate(180deg);
}
}
/* Use different images per theme */
<picture>
<source srcset="logo-dark.png"
media="(prefers-color-scheme: dark)">
<img src="logo-light.png" alt="Logo">
</picture>
For SVG icons, use currentColor for fills and strokes. This makes SVGs automatically adapt to the text color of their parent element:
<svg fill="currentColor" viewBox="0 0 24 24"> <path d="M12 2L2 22h20L12 2z"/> </svg>
aria-live regions sparingly.prefers-reduced-motion check:@media (prefers-reduced-motion: no-preference) {
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
}
/* tailwind.config.js */
module.exports = {
darkMode: 'class', // or 'media' for system-only
}
/* Usage */
<div class="bg-white dark:bg-gray-900
text-black dark:text-white">
Content
</div>
// useTheme.js
import { useState, useEffect } from 'react';
export function useTheme() {
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'light';
return localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light');
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
return { theme, toggle };
}
Using pure black (#000000). Pure black backgrounds cause "halation" where white text appears to bleed into the background. Use #0a0a0a, #111111, or #121212 instead.
Not testing form inputs. Browser default form styles often break in dark mode. Input fields, selects, and textareas need explicit dark mode styles:
@media (prefers-color-scheme: dark) {
input, textarea, select {
background: #1a1a1a;
color: #e8e8e8;
border: 1px solid #333;
}
}
Forgetting scrollbar styling. A bright white scrollbar on a dark page is jarring. Webkit browsers support scrollbar styling:
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #0a0a0a; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
}
Flash of incorrect theme (FOIT). If your theme is set via JavaScript, users may see the wrong theme flash briefly on load. Prevent this by adding the theme attribute in a blocking script in the <head>:
<script>
(function() {
var t = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
})();
</script>
Use our free CSS tools to validate your dark mode styles, check contrast ratios, and debug color issues.
Contrast Checker CSS Formatter