Dark mode has become a standard feature users expect on modern websites. Besides looking elegant, dark mode also reduces eye strain when browsing at night. This article covers two approaches to building dark mode: automatically following the operating system's preference, and manually with a toggle button the user can click.
The Basic Concept: CSS Variables for Theming
The key to a clean dark mode implementation is using CSS variables. Define all colors as variables, then change their values for the dark theme:
/* Light theme (default) */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
}
/* Dark theme */
[data-theme="dark"] {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-border: #334155;
--color-card: #1e293b;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-primary: #60a5fa;
--color-primary-hover: #93c5fd;
}
Applying the Variables to Elements
body {
background-color: var(--color-bg);
color: var(--color-text);
transition: background-color 0.3s ease, color 0.3s ease;
font-family: system-ui, sans-serif;
}
.card {
background: var(--color-card);
border: 1px solid var(--color-border);
box-shadow: 0 2px 8px var(--color-shadow);
border-radius: 12px;
padding: 24px;
}
.text-muted {
color: var(--color-text-muted);
}
.button {
background: var(--color-primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.button:hover {
background: var(--color-primary-hover);
}
input, textarea {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: 10px 14px;
border-radius: 8px;
width: 100%;
}
Method 1: Automatic via prefers-color-scheme
Browsers and CSS support the prefers-color-scheme media query, which automatically detects the user's operating system theme preference:
/* Light theme (default) */
:root {
--color-bg: #ffffff;
--color-text: #1e293b;
--color-card: #f8fafc;
--color-border: #e2e8f0;
}
/* Automatically switch to dark if the OS uses dark mode */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-card: #1e293b;
--color-border: #334155;
}
}
With this approach, no JavaScript is needed at all. The website automatically matches the theme to the user's system settings.
Method 2: Manual Toggle with JavaScript
Create a toggle button that changes the data-theme attribute on the <html> element:
<!-- HTML: Toggle Button -->
<button class="theme-toggle-btn" id="toggleTheme" aria-label="Toggle dark mode">
<span id="themeIcon">☾</span> Dark Mode
</button>
<!-- CSS for the Toggle Button -->
<style>
.theme-toggle-btn {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.theme-toggle-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
</style>
<!-- JavaScript -->
<script>
const toggleBtn = document.getElementById('toggleTheme');
const themeIcon = document.getElementById('themeIcon');
const html = document.documentElement;
// Check the preference saved in localStorage
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
html.setAttribute('data-theme', 'dark');
themeIcon.textContent = '☀'; // sun icon
toggleBtn.lastChild.textContent = ' Light Mode';
}
toggleBtn.addEventListener('click', function () {
const currentTheme = html.getAttribute('data-theme');
if (currentTheme === 'dark') {
html.removeAttribute('data-theme');
localStorage.setItem('theme', 'light');
themeIcon.textContent = '☽'; // moon icon
toggleBtn.lastChild.textContent = ' Dark Mode';
} else {
html.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
themeIcon.textContent = '☀'; // sun icon
toggleBtn.lastChild.textContent = ' Light Mode';
}
});
</script>
Combining Both Approaches
The best approach is a combination of both: default to the system preference, but the user can still change it manually:
/* Detect the system preference by default */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-card: #1e293b;
--color-border: #334155;
}
}
/* Manual dark mode */
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-card: #1e293b;
--color-border: #334155;
}
Smooth Transitions
Add a transition so the theme switch feels smooth and not abrupt:
body,
.card,
input,
textarea,
button {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
Tips for a Good Dark Mode
- Avoid pure black (
#000000) for the background — use dark colors like#0f172aor#111827that are more comfortable for the eyes. - Make sure the text and background contrast meets WCAG accessibility standards (at least 4.5:1 for normal text).
- Save the user's preference in
localStorageso the choice persists when the page is refreshed. - Don't forget to adjust images and icons too — consider using a CSS filter for images that are too bright in dark mode.
/* Optimize images in dark mode */
[data-theme="dark"] img:not([class*="logo"]) {
filter: brightness(0.85) contrast(1.05);
}
Conclusion
Building dark mode with CSS isn't complicated if you use CSS variables as the foundation. The prefers-color-scheme approach makes the website automatically match the user's system theme, while a JavaScript toggle button gives the user full control. Save the preference in localStorage for a more consistent experience. With a well-implemented dark mode, your website will feel more modern, comfortable, and professional.