Add Dark Mode to Your EmDash Site
Dark mode is table stakes now. Visitors expect it, and the jarring flash of white on load when someone has dark mode set is the kind of thing that makes a site feel half-finished. I went through three approaches before landing on the one that actually works well with EmDash — CSS variables with a cookie-based toggle and an inline blocking script to prevent the flash.
The approach fits naturally with EmDash's design token system. Your theme colors are already CSS variables in global.css. Dark mode is just a second set of values for those same variables. No framework needed, no JavaScript bundle cost, and the toggle persists across sessions via a cookie that's readable server-side.
The CSS Variable Approach
Every color in the design system is a CSS custom property. Light mode values are the defaults. When the .dark class is on the html element, a second block of variable definitions overrides them. The browser does all the work — there's no JavaScript color calculation at all.
The key rule: every --color-* token needs a dark mode override. If you add a new semantic color token later and forget the dark mode block, you'll end up with a light color on a dark background. Build the habit of defining both values whenever you add a token.
@import 'tailwindcss';
@theme {
/* Light mode defaults */
--color-heading: oklch(15% 0 0);
--color-body: oklch(30% 0 0);
--color-muted: oklch(55% 0 0);
--color-surface: oklch(98% 0 0);
--color-surface-raised: oklch(100% 0 0);
--color-border: oklch(90% 0 0);
--color-accent: oklch(55% 0.18 250);
--color-accent-hover: oklch(48% 0.18 250);
}
/* Dark mode overrides -- applied when .dark is on <html> */
.dark {
--color-heading: oklch(95% 0 0);
--color-body: oklch(80% 0 0);
--color-muted: oklch(60% 0 0);
--color-surface: oklch(12% 0 0);
--color-surface-raised: oklch(18% 0 0);
--color-border: oklch(25% 0 0);
--color-accent: oklch(65% 0.18 250);
--color-accent-hover: oklch(72% 0.18 250);
} Preventing the Flash of Light Mode
This is the hard part. If you apply the dark class from JavaScript after the page renders, users with dark mode preference see the page flash white for a frame before the class applies. The fix is a tiny inline blocking script in the head — it runs before any CSS is painted, reads the saved preference from a cookie, and applies the class immediately.
The inline script runs before paint. The cookie is readable without JavaScript. The .dark class is on the html element before the first pixel is drawn.
The script is inline and blocking — this is intentional. It's tiny (under 200 bytes) and it must run before the browser renders anything. Deferring or async-loading it defeats the purpose. Place it as the very first child of the head element, before any stylesheets.
<head>
<!-- Inline blocking script: must be first to prevent flash -->
<script is:inline>
(function() {
// Read theme from cookie (set by toggle, persists server-side)
var theme = document.cookie.split('; ').reduce(function(acc, pair) {
var parts = pair.split('=');
return parts[0] === 'theme' ? parts[1] : acc;
}, null);
// Fall back to system preference if no cookie
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
})();
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<EmDashHead pageContext={pageContext} />
</head> The Toggle Button
The toggle button flips the .dark class on the html element and writes the preference to a cookie. The cookie has a one-year expiry and SameSite=Lax so it's readable server-side on subsequent requests — useful if you ever want to server-render the correct theme without the inline script.
I put the toggle in the header nav. An icon button labeled 'Toggle dark mode' works well. Use aria-pressed to communicate the current state to screen readers — update it when the class changes.
<button
id="theme-toggle"
aria-label="Toggle dark mode"
aria-pressed="false"
class="p-2 rounded-md text-body hover:text-heading transition-colors"
>
<svg id="icon-sun" aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
<script>
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Sync initial aria-pressed state
toggle.setAttribute('aria-pressed', html.classList.contains('dark').toString());
toggle.addEventListener('click', () => {
const isDark = html.classList.toggle('dark');
toggle.setAttribute('aria-pressed', isDark.toString());
// Persist preference in cookie (1 year, SameSite=Lax)
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `theme=${isDark ? 'dark' : 'light'}; expires=${expires}; path=/; SameSite=Lax`;
});
</script> The Tailwind dark: Variant
Tailwind 4's dark: variant works with class-based dark mode when you configure it correctly. Add darkMode: 'class' to your Tailwind config — in Tailwind 4 that means adding a @variant directive in your CSS file.
In practice, because all your colors are semantic CSS variables, you rarely need dark: prefixes. The variable value changes; the class name stays the same. The dark: variant is most useful for structural changes — different border radius in dark mode, different shadow style, or toggling visibility of decorative elements.
Next: Create a custom EmDash theme