A practical walkthrough of building dark mode with CSS custom properties, localStorage persistence, smooth transitions, and image handling. Includes a full theme switcher implementation with accessibility considerations.
Why Dark Mode Matters More Than You Think
When we started getting dark mode requests from JekCMS users back in early 2025, I honestly thought it was a nice-to-have feature. Then I looked at the numbers. Our analytics across 40+ JekCMS installations showed that 62% of visitors between 8 PM and 6 AM were using OS-level dark mode. That is not a niche preference — that is the majority of evening traffic.
The deeper issue is that a bright white page at 11 PM does not just look bad. It physically hurts. OLED screens make this worse because each white pixel is a tiny light source. Dark mode reduces power consumption on OLED by up to 60% at full brightness, which is a meaningful battery difference on mobile devices.
I spent three weeks refactoring our theme system to support dark mode properly, and I want to share everything I learned — the wins, the mistakes, and the edge cases nobody warns you about.
The CSS Custom Properties Strategy
The foundation of any maintainable dark mode system is CSS custom properties (variables). If you try to do this with separate stylesheets or class overrides on individual elements, you will end up with an unmaintainable mess within a month.
Here is the core principle: define every color as a custom property on :root, then override those properties when dark mode is active. Every element in your stylesheet references properties, never raw color values.
:root {
/* Surface colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-bg-tertiary: #e8e8e8;
--color-bg-elevated: #ffffff;
/* Text colors */
--color-text-primary: #1a1a1a;
--color-text-secondary: #555555;
--color-text-tertiary: #888888;
--color-text-inverse: #ffffff;
/* Border colors */
--color-border-default: #d0d0d0;
--color-border-subtle: #e5e5e5;
/* Brand colors */
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent-text: #ffffff;
/* Semantic colors */
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-colors: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}
Notice that I am not naming variables like --dark-bg or --light-text. That is a common mistake. Name them by function, not by appearance. --color-bg-primary is the main background, regardless of whether it is white or dark gray.
The Dark Mode Override
The dark theme overrides these same properties. I use a [data-theme="dark"] attribute on the <html> element rather than a CSS class because data attributes feel semantically correct — they describe a state, not a style.
[data-theme="dark"] {
--color-bg-primary: #0f0f0f;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #262626;
--color-bg-elevated: #1f1f1f;
--color-text-primary: #e5e5e5;
--color-text-secondary: #a3a3a3;
--color-text-tertiary: #737373;
--color-text-inverse: #0f0f0f;
--color-border-default: #333333;
--color-border-subtle: #262626;
--color-accent: #60a5fa;
--color-accent-hover: #93c5fd;
--color-accent-text: #0f0f0f;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}
A few things worth noting here. The dark background is #0f0f0f, not pure #000000. Pure black creates too much contrast with text and causes visual fatigue. On OLED screens, pure black also creates a "smearing" effect during scrolling because pixels turn completely off and have a slight delay turning back on. A very dark gray like #0f0f0f or #121212 avoids both problems.
The accent color shifts from #2563eb (blue-700) to #60a5fa (blue-400). Darker blues do not have enough contrast against dark backgrounds. You need to bump your accent colors lighter by 2-3 steps in the color scale.
The prefers-color-scheme Media Query
Before building any toggle UI, you should respect the user's operating system preference. The prefers-color-scheme media query lets you detect whether someone has enabled dark mode at the OS level.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg-primary: #0f0f0f;
--color-bg-secondary: #1a1a1a;
/* ... all dark overrides ... */
}
}
That selector — :root:not([data-theme="light"]) — is the key. It means: apply dark mode when the OS prefers it, UNLESS the user has explicitly chosen light mode on this site. This creates a three-state system: auto (follow OS), forced light, and forced dark.
The priority order works out to: explicit user choice beats OS preference, which beats the default light theme. This matches how every major application handles it.
Building the Theme Switcher
The toggle needs to do three things: switch the visual theme immediately, save the preference to localStorage, and load the saved preference before the page renders to prevent flash of wrong theme (FOWT).
class ThemeSwitcher {
constructor() {
this.STORAGE_KEY = 'jekcms-theme';
this.theme = this.getSavedTheme();
this.applyTheme(this.theme);
this.bindToggle();
}
getSavedTheme() {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
return 'auto';
}
getEffectiveTheme() {
if (this.theme !== 'auto') return this.theme;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
applyTheme(theme) {
this.theme = theme;
const effective = this.getEffectiveTheme();
document.documentElement.setAttribute('data-theme', effective);
localStorage.setItem(this.STORAGE_KEY, theme);
this.updateToggleIcon(effective);
}
cycle() {
const order = ['auto', 'light', 'dark'];
const current = order.indexOf(this.theme);
const next = order[(current + 1) % order.length];
this.applyTheme(next);
}
bindToggle() {
const btn = document.querySelector('[data-theme-toggle]');
if (!btn) return;
btn.addEventListener('click', () => this.cycle());
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (this.theme === 'auto') {
this.applyTheme('auto');
}
});
}
updateToggleIcon(effective) {
const btn = document.querySelector('[data-theme-toggle]');
if (!btn) return;
const icons = { auto: '◑', light: '☀', dark: '☾' };
const labels = { auto: 'Auto', light: 'Light', dark: 'Dark' };
btn.textContent = icons[this.theme];
btn.setAttribute('aria-label', 'Theme: ' + labels[this.theme]);
}
}
document.addEventListener('DOMContentLoaded', () => new ThemeSwitcher());
The three-state cycle (auto, light, dark) is better than a binary toggle because it lets users go back to "follow my OS" without having to remember what their OS is set to. I stole this idea from Firefox's reader mode and it just makes more sense.
Preventing Flash of Wrong Theme
The biggest dark mode UX problem is the flash of white that appears before JavaScript runs. If someone prefers dark mode and your page loads white for 200ms before switching, that flash is jarring — especially at night.
The fix is a tiny inline script in the <head>, before any stylesheet loads:
<script>
(function() {
var saved = localStorage.getItem('jekcms-theme');
var theme = saved || 'auto';
var effective = theme;
if (theme === 'auto') {
effective = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', effective);
})();
</script>
This runs synchronously before the browser paints anything, so the correct theme is applied from the very first frame. It is one of the few cases where inline JavaScript in the head is the right approach.
Transition Animations
A sudden color swap looks broken. A smooth transition feels intentional. Apply a transition to all elements that use theme colors:
*,
*::before,
*::after {
transition: var(--transition-colors);
}
This is the simple approach and it works fine for most sites. The 0.2s duration is short enough to feel snappy but long enough to be visible. However, this wildcard transition has a cost: it applies to every element on the page, including ones that will never change color.
For performance-critical pages, target specific elements instead:
body,
.header,
.sidebar,
.card,
.btn,
input,
textarea,
select,
pre,
code {
transition: var(--transition-colors);
}
On a page with 500+ DOM elements, the targeted approach reduces the number of elements the browser has to check for transition changes from 500+ to maybe 50. In practice, I have never measured a real performance difference on modern browsers, but it is technically more efficient.
Image Handling in Dark Mode
Images are the part of dark mode that nobody gets right on the first try. There are several categories of images, and each needs different treatment.
Photographs
Regular photos should not be modified. Do not apply any filter or opacity change to photography. A photo of a sunset should look the same in both modes. The only thing you might do is add a subtle border or shadow so the image edges are distinguishable from a dark background:
[data-theme="dark"] .post-image img {
box-shadow: 0 0 0 1px var(--color-border-subtle);
}
Diagrams and Screenshots
White-background diagrams and screenshots look like glowing rectangles in dark mode. The best solution is to provide separate versions — a light diagram and a dark diagram — using the <picture> element:
<picture>
<source srcset="diagram-dark.avif"
media="(prefers-color-scheme: dark)"
type="image/avif">
<source srcset="diagram-light.avif" type="image/avif">
<img src="diagram-light.png" alt="Architecture diagram">
</picture>
When separate versions are not available, a CSS filter can reduce the brightness without inverting colors:
[data-theme="dark"] .diagram img {
filter: brightness(0.85) contrast(1.1);
}
Logos and Icons
SVG icons that use currentColor adapt automatically. For raster logos with transparent backgrounds, you might need a light version for dark backgrounds. For logos on solid backgrounds, the same brightness reduction works:
[data-theme="dark"] .partner-logo img {
filter: brightness(0.9);
opacity: 0.85;
}
Decorative Background Images
Background images used for texture or atmosphere often need an overlay in dark mode:
.hero {
position: relative;
}
[data-theme="dark"] .hero::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
pointer-events: none;
}
Syntax Highlighting Themes
If your site includes code blocks — and any CMS blog will — you need matching syntax highlighting themes. JekCMS uses a custom scheme based on the One Dark/One Light pairing.
/* Light theme syntax colors */
:root {
--code-bg: #f6f8fa;
--code-text: #24292e;
--code-keyword: #d73a49;
--code-string: #032f62;
--code-comment: #6a737d;
--code-function: #6f42c1;
--code-number: #005cc5;
--code-operator: #d73a49;
}
/* Dark theme syntax colors */
[data-theme="dark"] {
--code-bg: #161b22;
--code-text: #c9d1d9;
--code-keyword: #ff7b72;
--code-string: #a5d6ff;
--code-comment: #8b949e;
--code-function: #d2a8ff;
--code-number: #79c0ff;
--code-operator: #ff7b72;
}
pre, code {
background: var(--code-bg);
color: var(--code-text);
}
.token-keyword { color: var(--code-keyword); }
.token-string { color: var(--code-string); }
.token-comment { color: var(--code-comment); font-style: italic; }
.token-function { color: var(--code-function); }
.token-number { color: var(--code-number); }
Form Element Styling
Form elements are the most frustrating part of dark mode because browsers apply their own styling. Inputs, textareas, and selects need explicit dark mode styles or they will remain white.
input,
textarea,
select {
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-tertiary);
}
/* Fix autofill background in dark mode */
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text-primary);
-webkit-box-shadow: 0 0 0px 1000px var(--color-bg-elevated) inset;
transition: background-color 5000s ease-in-out 0s;
}
That autofill hack is ugly but necessary. Chrome's autofill applies an opaque yellow background that you cannot override with normal CSS. The box-shadow trick covers it with your theme color, and the absurdly long transition duration prevents Chrome from animating back to yellow.
Custom Select Dropdowns
Native <select> dropdowns inherit some styles from the OS and ignore your CSS in certain browsers. If your design requires consistent dropdown styling across light and dark modes, you will need a custom dropdown component. For most content sites, the native select with the background/color overrides above is good enough.
Testing Across Browsers
Dark mode behavior varies between browsers more than you might expect. Here is what I found testing across the major ones:
- Chrome/Edge (Chromium):
prefers-color-schemefollows the OS setting. Autofill backgrounds need the box-shadow hack. Custom scrollbar colors work via::-webkit-scrollbar. - Firefox: Has its own dark mode override that can force dark colors on pages that do not support it. Your explicit dark mode implementation prevents Firefox from applying its own. Scrollbar colors use the standard
scrollbar-colorproperty. - Safari: Respects
prefers-color-schemeon macOS and iOS. Thecolor-schemeCSS property is particularly important here — Safari uses it to determine scrollbar appearance, form control colors, and the default background during page load.
Add this to both your light and dark themes:
:root {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
The color-scheme property tells the browser which color schemes your page supports. In Safari, this changes the default background color during page load (preventing white flash), adjusts scrollbar appearance, and modifies native form control colors. It is a single line that solves multiple problems simultaneously.
Scrollbar Styling
A bright scrollbar on a dark page is a dead giveaway that dark mode was an afterthought.
/* Firefox */
* {
scrollbar-color: var(--color-border-default) var(--color-bg-secondary);
scrollbar-width: thin;
}
/* Chromium */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-default);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
Common Mistakes I Made (So You Do Not Have To)
Mistake 1: Using opacity instead of separate colors
I initially tried to darken elements by reducing opacity. This makes text unreadable because it reduces the contrast ratio. Always use distinct color values for each theme.
Mistake 2: Forgetting box-shadow colors
Shadows with rgba(0,0,0,0.1) are invisible on dark backgrounds. Dark mode shadows need higher opacity values — typically 0.3 to 0.5 — to be visible against dark surfaces. This is why the shadow values are included in the custom properties.
Mistake 3: Not testing with actual content
Dark mode with placeholder text looks fine. Dark mode with a 2000-word article that includes embedded tweets, YouTube videos, code blocks, and a mix of inline images reveals all the edge cases. Test with your heaviest content pages first.
Mistake 4: Ignoring third-party embeds
YouTube embeds, Google Maps, social media widgets — none of these respect your dark mode. The best you can do is wrap them in a container and reduce brightness slightly:
[data-theme="dark"] .embed-container iframe {
filter: brightness(0.9);
}
The Complete Toggle Button HTML
<button
data-theme-toggle
type="button"
class="theme-toggle"
aria-label="Toggle theme"
title="Toggle theme">
◑
</button>
<style>
.theme-toggle {
position: fixed;
bottom: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border: 1px solid var(--color-border-default);
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 1.2rem;
cursor: pointer;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-colors);
}
.theme-toggle:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
</style>
Note the absence of border-radius — square corners are a deliberate design choice in JekCMS. The toggle sits at the bottom-right corner where it is accessible but not distracting. The fixed positioning means it is always available during scrolling.
Performance Impact
After deploying dark mode across our test installations, here is what we measured:
- CSS file size increase: 1.8 KB (gzipped) for all dark mode overrides
- JavaScript: 680 bytes minified for the theme switcher
- First paint impact: Undetectable (inline head script adds less than 1ms)
- Reflow on toggle: Single style recalculation, no layout shift
The CSS custom properties approach means the browser only needs to recompute property values when the theme changes — it does not need to re-evaluate selector matches or recalculate specificity. This is why custom properties are fundamentally faster for theming than class-based approaches that swap dozens of selectors.
Accessibility Considerations
Dark mode is not just a visual preference — it is an accessibility feature for people with light sensitivity, migraines, and certain visual impairments. Ensure your dark theme maintains WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text). The color values I listed above all meet AA compliance.
Use a contrast checker during development. I keep the WebAIM contrast checker open in a tab and test every text/background combination in both themes. It takes 10 minutes and prevents accessibility failures.
The theme toggle must be keyboard accessible (it is, since we used a <button> element) and must announce its state to screen readers via aria-label. When the theme changes, the label updates to reflect the current mode.