Dark Mode Implementation: CSS Custom Properties, Color Schemes, and UX

Dark mode is no longer a niche feature—users expect it. Operating systems have offered system-wide dark mode for years, and users increasingly prefer reduced luminance interfaces, especially for reading and late-night browsing. Implementing dark mode correctly requires more than inverting colors.
CSS Custom Properties: The Foundation
CSS custom properties enable clean theme switching. Define all color values as custom properties on :root, then override them for dark mode:
:root {
/* Light theme (default) */
--color-surface: #ffffff;
--color-surface-secondary: #f5f5f5;
--color-text-primary: #1a1a1a;
--color-text-secondary: #525252;
--color-text-muted: #a3a3a3;
--color-border: #e5e5e5;
--color-brand: #0ea5e9;
--color-brand-hover: #0284c7;
/* Shadows for light mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
}
[data-theme="dark"] {
--color-surface: #171717;
--color-surface-secondary: #262626;
--color-text-primary: #fafafa;
--color-text-secondary: #d4d4d4;
--color-text-muted: #737373;
--color-border: #404040;
--color-brand: #38bdf8;
--color-brand-hover: #7dd3fc;
/* Softer shadows for dark mode — dark surfaces need less shadow */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}
Use the properties everywhere in your stylesheets. Switching themes becomes a single attribute change:
.card {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
prefers-color-scheme: Respecting System Preferences
The prefers-color-scheme media query detects the user's OS-level theme preference. Apply it alongside your toggle to respect system settings:
/* Respect system dark mode when no manual override exists */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-surface: #171717;
/* ... all dark mode variables */
}
}
But giving users manual control is essential. A toggle with three states works best:
function initTheme() {
const stored = localStorage.getItem('theme'); // 'light', 'dark', or null
if (stored) {
document.documentElement.setAttribute('data-theme', stored);
} else {
// No stored preference, follow system
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}
}
// Update theme when system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
function toggleTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
Avoiding Flash of Wrong Theme
When the page loads, the browser paints before JavaScript runs. A flash of the light theme followed by a switch to dark mode is jarring. Fix it with an inline script in the <head>:
<head>
<script>
(function() {
const stored = localStorage.getItem('theme');
if (stored) {
document.documentElement.setAttribute('data-theme', stored);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
This runs synchronously before the first paint, so the correct theme is applied immediately.
Images and Media in Dark Mode
Images designed for light mode often look wrong in dark mode—bright backgrounds in images stand out against dark UI. Use CSS filters or provide dark-mode variants:
[data-theme="dark"] img {
/* Slightly reduced brightness and increased contrast for photos */
filter: brightness(0.85) contrast(1.1);
}
[data-theme="dark"] .logo {
/* Replace logo with inverted version */
content: url('/logo-dark.svg');
}
For more control, provide explicit dark-mode image sources:
<picture>
<source srcset="/diagram-dark.svg" media="(prefers-color-scheme: dark)" />
<source srcset="/diagram-light.svg" media="(prefers-color-scheme: light)" />
<img src="/diagram-light.svg" alt="Architecture diagram" />
</picture>
Smooth Theme Transitions
Instantaneous theme changes are visually jarring. Add a transition on color properties for a smooth experience:
*,
*::before,
*::after {
transition: background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
Use a class toggle to disable transitions when the page first loads, preventing animations on initial paint:
/* Disable transitions during initial load */
.theme-ready *,
.theme-ready *::before,
.theme-ready *::after {
transition: none !important;
}
// Enable transitions after first paint
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-ready');
});
Testing Dark Mode
Dark mode introduces new accessibility failure modes. Test all combinations:
- Color contrast in both themes (especially muted text on dark surfaces)
- Focus indicators visible against both backgrounds
- Form field borders legible in both themes
- Charts and data visualization with dual palettes
// Automated contrast check for both themes
async function checkThemeContrast(theme) {
document.documentElement.setAttribute('data-theme', theme);
const results = await new AxeBuilder({ page }).analyze();
const contrastIssues = results.violations.filter(v => v.id === 'color-contrast');
console.log(`${theme}: ${contrastIssues.length} contrast issues`);
}
Dark mode done well is invisible—users get a comfortable reading experience without thinking about it. Done poorly, it creates readability problems and design inconsistencies. CSS custom properties, system preference detection, and fallback-handling make it a robust feature rather than an afterthought.
Our custom UI/UX design team implements dark mode with full accessibility testing, ensuring a polished experience in both themes.
Related Insights

Accessibility by Design: Building Inclusive Digital Products from Day One
Build accessible digital products from day one with inclusive design principles, WCAG compliance strategies, and practical implementation patterns.

Accessibility Testing Automation: axe-core, Lighthouse, and CI Integration
Learn automated accessibility testing with axe-core, Lighthouse CI, and integration into CI/CD pipelines for catching issues before they reach production.

Building Accessible React Applications: WCAG 2.2 Compliance Guide
A guide to building WCAG 2.2 compliant React applications including semantic HTML, ARIA attributes, keyboard navigation, focus management, and automated accessibility testing.