Building Accessible Dark Mode with CSS Custom Properties
Dark Mode Done Right
Dark mode is no longer a nice-to-have feature. Users expect it, and when implemented properly, it reduces eye strain, saves battery on OLED screens, and makes your site more accessible. But slapping background: #000 on everything is not dark mode. It is an accessibility nightmare with crushing contrast that is harder to read than light mode.
Here is how I built dark mode for my portfolio site using CSS custom properties, with careful attention to contrast ratios and accessibility.
CSS Custom Properties as a Theme System
The foundation is a set of CSS custom properties (CSS variables) that define your colour palette:
:root {
/* Light theme (default) */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-tertiary: #e9ecef;
--color-text-primary: #1a1a2e;
--color-text-secondary: #4a4a6a;
--color-text-muted: #6c757d;
--color-accent: #4d94d4;
--color-accent-hover: #3a7bc0;
--color-border: #dee2e6;
--color-code-bg: #f5f5f5;
--color-shadow: rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--color-bg-primary: #1a1a2e;
--color-bg-secondary: #222240;
--color-bg-tertiary: #2a2a4a;
--color-text-primary: #e8e8f0;
--color-text-secondary: #b8b8d0;
--color-text-muted: #8888a8;
--color-accent: #6db3f2;
--color-accent-hover: #8ec5f5;
--color-border: #3a3a5a;
--color-code-bg: #2a2a4a;
--color-shadow: rgba(0, 0, 0, 0.3);
}Notice that the dark theme does not simply invert colours. The background is a dark navy (#1a1a2e), not pure black. The text is off-white (#e8e8f0), not pure white. This reduces contrast to a comfortable level while maintaining WCAG AA compliance.
Using the Variables
Every colour reference in your CSS uses the custom properties:
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
.card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
box-shadow: 0 2px 4px var(--color-shadow);
}
a {
color: var(--color-accent);
}
a:hover {
color: var(--color-accent-hover);
}When the data-theme attribute changes on the root element, every colour updates instantly. No class toggling on individual elements, no duplicate stylesheets.
The Toggle JavaScript
The theme toggle needs to do three things: switch the theme, save the preference, and respect system preferences on first visit:
function initTheme() {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// Initialize on page load
initTheme();
// Listen for 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'
);
}
});The priority order is: saved preference, then system preference, then default (light). If the user has not explicitly chosen a theme, changes to their system preference are respected in real-time.
Preventing Flash of Wrong Theme
If the theme JavaScript runs after the page renders, users see a flash of the default theme. Prevent this by inlining the theme detection in the <head>:
<script>
(function() {
var theme = localStorage.getItem('theme');
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>This runs before the body renders, so the correct theme is applied immediately.
Accessibility Considerations
Contrast Ratios
WCAG AA requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text. I checked every colour combination with a contrast checker:
- Primary text on primary background: 13.8:1 (light), 12.1:1 (dark)
- Secondary text on primary background: 7.2:1 (light), 6.8:1 (dark)
- Accent colour on primary background: 4.6:1 (light), 5.2:1 (dark)
The dark theme accent colour (#6db3f2) is lighter than the light theme accent (#4d94d4) to maintain sufficient contrast against the dark background.
Focus Indicators
Focus indicators must be visible in both themes:
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}Using the accent colour ensures focus rings are visible regardless of theme.
Images and Media
Some images look wrong in dark mode. I use CSS to slightly reduce brightness and add a subtle border:
[data-theme="dark"] img {
filter: brightness(0.9);
border: 1px solid var(--color-border);
}The Toggle Button
The toggle button itself should be accessible:
<button
onclick="toggleTheme()"
aria-label="Toggle dark mode"
title="Toggle dark mode"
class="theme-toggle"
>
<span class="theme-toggle-icon"></span>
</button>Always include aria-label for icon-only buttons. Screen reader users need to know what the button does.
Testing
Test dark mode with actual users. Automated contrast checkers catch ratio violations, but they do not catch readability issues caused by colour choices that technically pass but are still hard to read. Particularly check code blocks, form elements, and any third-party embeds that might not respect your theme variables.