Table of Contents
- Dark theme
- Why Dark Mode Matters in 2025
- Understanding Dark Mode Implementation
- CSS Custom Properties for Dark Mode
- JavaScript Toggle Functionality
- Advanced Dark Mode Techniques
- User Preference Detection
- Accessibility Considerations
- Performance Optimization
- Best Practices and Common Pitfalls
- Testing and Browser Compatibility
- Real-World Examples
- Conclusion
Introduction
Dark mode has evolved from a nice-to-have feature to an essential part of modern web design. With major platforms like Twitter, GitHub, and Google implementing dark themes, users now expect this functionality across all digital experiences.
This comprehensive guide will walk you through building a robust, accessible, and performant dark mode feature using modern CSS and JavaScript techniques. You’ll learn everything from basic implementation to advanced optimization strategies.
Why Dark Mode Matters in 2025
User Experience Benefits
Dark mode isn’t just a trend – it provides tangible benefits for users:
- Reduced Eye Strain: Studies show dark interfaces can reduce eye fatigue, especially in low-light environments
- Battery Savings: OLED screens consume up to 63% less power when displaying dark themes
- Better Sleep: Reduced blue light exposure can improve sleep quality
- Enhanced Focus: Dark backgrounds can help users focus on content without distraction
Design and Business Impact
- Modern Appeal: 82% of smartphone users prefer apps with dark mode options
- User Retention: Apps with dark mode see 15% higher engagement rates
- Accessibility: Supports users with light sensitivity and visual impairments
- Brand Differentiation: Professional, modern appearance that stands out
Understanding Dark Mode Implementation
Core Concepts
Before diving into code, let’s understand the fundamental approaches to implementing dark mode:
1. CSS Custom Properties (Recommended)
Using CSS custom properties (CSS variables) for dynamic theme switching.
2. CSS Classes
Traditional approach using separate CSS classes for light and dark themes.
3. CSS Media Queries
Automatic detection using prefers-color-scheme media query.
Implementation Strategy
The most effective approach combines all three methods:
- System preference detection for initial theme
- CSS custom properties for smooth transitions
- JavaScript for user controls and persistence
CSS Custom Properties for Dark Mode
Setting Up Color Variables
First, define your color palette using CSS custom properties:
:root {
/* Light theme colors */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--accent-color: #007bff;
--shadow: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
/* Dark theme colors */
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--border-color: #333333;
--accent-color: #4dabf7;
--shadow: rgba(255, 255, 255, 0.1);
}
Applying Variables to Elements
Use these variables throughout your stylesheet:
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 2px 4px var(--shadow);
transition: all 0.3s ease;
}
.btn-primary {
background-color: var(--accent-color);
color: var(--bg-primary);
}
/* Text elements */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
}
p, span, div {
color: var(--text-primary);
}
.text-muted {
color: var(--text-secondary);
}
Advanced Color Calculations
For more sophisticated theming, use CSS calc() and color functions:
:root {
--primary-hue: 210;
--primary-saturation: 100%;
}
[data-theme="light"] {
--bg-primary: hsl(var(--primary-hue), 0%, 100%);
--bg-secondary: hsl(var(--primary-hue), 10%, 98%);
--text-primary: hsl(var(--primary-hue), 5%, 10%);
}
[data-theme="dark"] {
--bg-primary: hsl(var(--primary-hue), 5%, 7%);
--bg-secondary: hsl(var(--primary-hue), 8%, 12%);
--text-primary: hsl(var(--primary-hue), 5%, 95%);
}
JavaScript Toggle Functionality
Basic Toggle Implementation
Here’s a complete JavaScript implementation for dark mode toggle:
class DarkModeToggle {
constructor() {
this.theme = localStorage.getItem('theme') || 'light';
this.toggleBtn = document.querySelector('[data-theme-toggle]');
this.init();
}
init() {
// Set initial theme
this.setTheme(this.theme);
// Add event listener
if (this.toggleBtn) {
this.toggleBtn.addEventListener('click', () => this.toggle());
}
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.handleSystemChange(e));
}
setTheme(theme) {
this.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
this.updateToggleButton();
// Dispatch custom event
document.dispatchEvent(new CustomEvent('themechange', {
detail: { theme }
}));
}
toggle() {
const newTheme = this.theme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
}
updateToggleButton() {
if (this.toggleBtn) {
this.toggleBtn.setAttribute('aria-label',
`Switch to ${this.theme === 'light' ? 'dark' : 'light'} mode`);
// Update button icon or text
const icon = this.toggleBtn.querySelector('.theme-icon');
if (icon) {
icon.textContent = this.theme === 'light' ? '🌙' : '☀️';
}
}
}
handleSystemChange(e) {
if (!localStorage.getItem('theme')) {
this.setTheme(e.matches ? 'dark' : 'light');
}
}
// Get current theme
getCurrentTheme() {
return this.theme;
}
// Check if dark mode is active
isDarkMode() {
return this.theme === 'dark';
}
}
// Initialize dark mode toggle
document.addEventListener('DOMContentLoaded', () => {
window.darkMode = new DarkModeToggle();
});
HTML Toggle Button
Create an accessible toggle button:
<button
data-theme-toggle
class="theme-toggle"
aria-label="Switch to dark mode"
type="button"
>
<span class="theme-icon" aria-hidden="true">🌙</span>
<span class="sr-only">Toggle theme</span>
</button>
CSS for Toggle Button
Style the toggle button:
.theme-toggle {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 50%;
width: 48px;
height: 48px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
background: var(--accent-color);
color: var(--bg-primary);
transform: scale(1.1);
}
.theme-toggle:focus {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
.theme-icon {
font-size: 1.2rem;
transition: transform 0.3s ease;
}
.theme-toggle:hover .theme-icon {
transform: rotate(20deg);
}
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
Advanced Dark Mode Techniques
Smooth Transitions
Implement smooth theme transitions to enhance user experience:
* {
transition:
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Disable transitions during theme change to prevent flash */
.theme-transition {
transition: none !important;
}
JavaScript to handle smooth transitions:
setTheme(theme) {
// Temporarily disable transitions
document.body.classList.add('theme-transition');
this.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Re-enable transitions after a brief delay
setTimeout(() => {
document.body.classList.remove('theme-transition');
}, 50);
this.updateToggleButton();
}
Image and Media Handling
Handle images and media elements in dark mode:
/* Invert images in dark mode */
[data-theme="dark"] img:not(.no-invert) {
filter: brightness(0.8) contrast(1.2);
}
/* Specific handling for logos */
[data-theme="dark"] .logo {
filter: brightness(0) invert(1);
}
/* Video and iframe adjustments */
[data-theme="dark"] video,
[data-theme="dark"] iframe {
filter: brightness(0.9);
}
CSS Grid and Flexbox Considerations
Ensure layout components work well with theme changes:
.grid-container {
display: grid;
gap: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: linear-gradient(
135deg,
var(--bg-primary) 0%,
var(--bg-secondary) 100%
);
}
User Preference Detection
System Preference Detection
Detect and respect user’s system preferences:
// Check system preference
function getSystemPreference() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// Enhanced initialization
class AdvancedDarkModeToggle extends DarkModeToggle {
constructor() {
super();
this.systemPreference = getSystemPreference();
this.userPreference = localStorage.getItem('theme');
}
init() {
// Priority: User preference > System preference > Default (light)
const initialTheme = this.userPreference || this.systemPreference || 'light';
this.setTheme(initialTheme);
super.init();
}
// Auto theme based on time
getAutoTheme() {
const hour = new Date().getHours();
return (hour >= 18 || hour <= 6) ? 'dark' : 'light';
}
}
CSS Media Query Integration
Use CSS to provide fallback styling:
/* Default light theme */
:root {
--bg-primary: #ffffff;
--text-primary: #000000;
}
/* System dark preference fallback */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #121212;
--text-primary: #ffffff;
}
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
Accessibility Considerations
ARIA Labels and Screen Readers
Make your dark mode toggle accessible:
<button
data-theme-toggle
class="theme-toggle"
aria-label="Toggle between light and dark mode"
aria-pressed="false"
type="button"
>
<svg aria-hidden="true" class="theme-icon">
<!-- SVG icon content -->
</svg>
</button>
Updated JavaScript for accessibility:
updateToggleButton() {
if (this.toggleBtn) {
const isDark = this.theme === 'dark';
this.toggleBtn.setAttribute('aria-label',
`Switch to ${isDark ? 'light' : 'dark'} mode`);
this.toggleBtn.setAttribute('aria-pressed', isDark.toString());
// Update icon
const icon = this.toggleBtn.querySelector('.theme-icon');
if (icon) {
icon.innerHTML = isDark
? '<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>'
: '<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>';
}
}
}
Color Contrast Compliance
Ensure WCAG 2.1 compliance:
:root {
/* Light theme - WCAG AA compliant */
--bg-primary: #ffffff;
--text-primary: #212529; /* Contrast ratio: 16.8:1 */
--text-secondary: #6c757d; /* Contrast ratio: 4.5:1 */
--accent-color: #0056b3; /* Contrast ratio: 7.1:1 */
}
[data-theme="dark"] {
/* Dark theme - WCAG AA compliant */
--bg-primary: #121212;
--text-primary: #e0e0e0; /* Contrast ratio: 15.8:1 */
--text-secondary: #b0b0b0; /* Contrast ratio: 7.3:1 */
--accent-color: #66b3ff; /* Contrast ratio: 8.2:1 */
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--text-primary: #000000;
--bg-primary: #ffffff;
}
[data-theme="dark"] {
--text-primary: #ffffff;
--bg-primary: #000000;
}
}
Focus Management
Ensure keyboard navigation works seamlessly:
.theme-toggle:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(var(--accent-color), 0.2);
}
/* High contrast focus indicators */
@media (prefers-contrast: high) {
.theme-toggle:focus-visible {
outline-width: 3px;
outline-color: var(--text-primary);
}
}
Performance Optimization
Minimize Layout Shifts
Prevent Cumulative Layout Shift (CLS) during theme changes:
/* Reserve space for elements that might change size */
.theme-toggle {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
}
/* Use transform instead of changing properties that trigger layout */
.card {
transform: translateZ(0); /* Force GPU acceleration */
}
.card:hover {
transform: translateY(-2px) translateZ(0);
}
Efficient CSS Loading
Optimize CSS delivery:
<!-- Critical CSS inline -->
<style>
/* Critical dark mode styles inline */
:root { --bg-primary: #ffffff; }
[data-theme="dark"] { --bg-primary: #121212; }
body { background-color: var(--bg-primary); }
</style>
<!-- Non-critical CSS -->
<link rel="preload" href="dark-mode.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="dark-mode.css"></noscript>
JavaScript Performance
Optimize the toggle implementation:
class OptimizedDarkModeToggle {
constructor() {
this.theme = this.getStoredTheme();
this.toggleBtn = null;
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.init();
}
init() {
// Use requestAnimationFrame for smooth updates
requestAnimationFrame(() => {
this.setTheme(this.theme);
this.bindEvents();
});
}
bindEvents() {
// Use event delegation
document.addEventListener('click', this.handleClick.bind(this));
// Throttle system preference changes
this.mediaQuery.addEventListener('change',
this.throttle(this.handleSystemChange.bind(this), 100));
}
handleClick(e) {
if (e.target.matches('[data-theme-toggle]')) {
e.preventDefault();
this.toggle();
}
}
throttle(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
getStoredTheme() {
try {
return localStorage.getItem('theme') ||
(this.mediaQuery.matches ? 'dark' : 'light');
} catch (e) {
return 'light';
}
}
}
Best Practices and Common Pitfalls
Do’s and Don’ts
✅ Do:
- Use CSS custom properties for maintainable theming
- Respect system preferences by default
- Implement smooth transitions
- Test color contrast ratios
- Provide keyboard accessibility
- Store user preferences
❌ Don’t:
- Invert all colors automatically
- Ignore accessibility guidelines
- Forget to handle images and media
- Use only black and white
- Neglect mobile experience
- Skip browser testing
Common Mistakes
1. Poor Color Choices
/* ❌ Poor contrast */
[data-theme="dark"] {
--text-color: #888888; /* Low contrast */
--bg-color: #333333;
}
/* ✅ Good contrast */
[data-theme="dark"] {
--text-color: #e0e0e0; /* High contrast */
--bg-color: #121212;
}
2. Missing System Integration
// ❌ Ignoring system preference
const theme = localStorage.getItem('theme') || 'light';
// ✅ Respecting system preference
const systemPrefers = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = localStorage.getItem('theme') || (systemPrefers ? 'dark' : 'light');
3. Inadequate Image Handling
/* ❌ Inverting all images */
[data-theme="dark"] img {
filter: invert(1);
}
/* ✅ Selective image handling */
[data-theme="dark"] .content-image {
filter: brightness(0.8) contrast(1.2);
}
[data-theme="dark"] .logo {
filter: brightness(0) invert(1);
}
Testing and Browser Compatibility
Cross-Browser Testing
Test your dark mode implementation across browsers:
- Chrome/Edge: Full support for CSS custom properties and prefers-color-scheme
- Firefox: Excellent support with good developer tools
- Safari: Good support, test on both desktop and mobile
- Internet Explorer: Limited support, provide fallbacks
Browser Compatibility Fallbacks
/* Fallback for older browsers */
.theme-light {
background-color: #ffffff;
color: #000000;
}
.theme-dark {
background-color: #121212;
color: #ffffff;
}
/* Modern browsers with CSS custom properties */
@supports (color: var(--custom)) {
.theme-light,
.theme-dark {
background-color: var(--bg-primary);
color: var(--text-primary);
}
}
Testing Checklist
- Theme toggle works correctly
- System preference detection
- Local storage persistence
- Keyboard navigation
- Screen reader compatibility
- Color contrast ratios (use WebAIM Contrast Checker)
- Mobile responsiveness
- Performance impact
- Image and media handling
- Form element styling
Real-World Examples
GitHub-Style Implementation
/* GitHub-inspired dark mode */
:root {
--color-canvas-default: #ffffff;
--color-canvas-subtle: #f6f8fa;
--color-fg-default: #24292f;
--color-fg-muted: #656d76;
--color-border-default: #d0d7de;
--color-accent-emphasis: #0969da;
}
[data-color-mode="dark"] {
--color-canvas-default: #0d1117;
--color-canvas-subtle: #161b22;
--color-fg-default: #f0f6fc;
--color-fg-muted: #7d8590;
--color-border-default: #30363d;
--color-accent-emphasis: #2f81f7;
}
body {
background-color: var(--color-canvas-default);
color: var(--color-fg-default);
}
.header {
background-color: var(--color-canvas-subtle);
border-bottom: 1px solid var(--color-border-default);
}
Material Design 3 Approach
/* Material Design 3 color system */
:root {
/* Light theme */
--md-sys-color-primary: #6750a4;
--md-sys-color-surface: #fffbfe;
--md-sys-color-on-surface: #1c1b1f;
--md-sys-color-surface-variant: #e7e0ec;
--md-sys-color-outline: #79747e;
}
[data-theme="dark"] {
/* Dark theme */
--md-sys-color-primary: #d0bcff;
--md-sys-color-surface: #1c1b1f;
--md-sys-color-on-surface: #e6e1e5;
--md-sys-color-surface-variant: #49454f;
--md-sys-color-outline: #938f99;
}
.card {
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline);
}
Conclusion
Implementing a robust dark mode feature requires careful consideration of user experience, accessibility, and performance. By following the techniques and best practices outlined in this guide, you’ll create a dark mode implementation that not only looks great but also provides genuine value to your users.
Key takeaways for successful dark mode implementation:
- Start with user needs – Respect system preferences and provide easy controls
- Use CSS custom properties – They provide the most flexible and maintainable approach
- Prioritize accessibility – Ensure proper contrast ratios and keyboard navigation
- Test thoroughly – Check across browsers, devices, and with assistive technologies
- Optimize performance – Minimize layout shifts and use efficient CSS/JavaScript
Remember that dark mode is not just about inverting colors – it’s about creating a cohesive, accessible, and delightful user experience that works in any lighting condition