🌙 🎨 ⚡
## What Dark Mode Is (And Isn't)
Dark mode is non-negotiable in 2026. Not a nice-to-have, not a "premium feature" — users expect it on every site that'll be read after sunset. The challenge isn't "should we?" but "how do we ship it without the white flash on load?" and "how do we make it maintainable?"
If you've shipped a dark mode that flashes white for 200ms while the theme script loads, you've felt the pain. If you've buried color overrides in 40 component files instead of centralising them, you've felt the complexity tax. This post covers the pattern we ship on every Aidxn project: CSS custom properties as the single source of truth, Tailwind's
dark: variant handling the media query, and a blocking inline script that kills the flash before the first paint.
## Why Naive dark: Fails
The Tailwind dark: variant is convenient — you write bg-white dark:bg-slate-900 and the framework handles the CSS. The problem: if the user prefers dark mode (system preference), their browser loads the light-themed HTML first, then the CSS, then the JavaScript that reads localStorage or system preference. In those 50–100ms, the white background is already painted.
The flashing white box is a cognitive jab. Users on dark mode feel it every time they visit.
The second problem: if you use dark: classnames scattered across 150 components, toggling dark mode becomes a scattered affair. You change one color token and have to trace through the codebase to find where else that shade is used. Maintenance becomes expensive.
## The CSS Variable Approach
Instead of relying on Tailwind's dark: variant alone, we centralise theme values as CSS custom properties. The layout looks like:
```css
:root {
--bg: #ffffff;
--text: #000000;
--border: #e5e7eb;
--accent: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--text: #f1f5f9;
--border: #334155;
--accent: #60a5fa;
}
}
html.dark {
--bg: #0f172a;
--text: #f1f5f9;
--border: #334155;
--accent: #60a5fa;
}
```
Then in your Tailwind config, you reference these variables:
```javascript
export default {
theme: {
colors: {
bg: 'var(--bg)',
text: 'var(--text)',
border: 'var(--border)',
accent: 'var(--accent)',
},
},
};
```
Now you write bg-bg text-text border-border in your HTML, and the color system is unified. Change --bg and it propagates everywhere. No scattered dark: variants. No hunting through components.
The html.dark rule handles explicit theme toggles — when the user clicks "dark mode," you add the dark class to <html> and the variables flip. The @media (prefers-color-scheme: dark) handles system preference for first-time visitors with no localStorage entry.
## The Blocking Inline Script
The flash happens because the browser paints the page before the theme script runs. We kill this by executing the theme logic in a blocking <script> tag in the <head>, before any layout is rendered.
```html
```
This script is tiny (~100 bytes gzipped). It blocks the parser, but only for microseconds. The payoff: no flash. The page renders in the correct theme from pixel one.
In Astro, put this in your Layout.astro as a raw inline script before the stylesheet import:
```astro
---
import Layout from '../../layouts/Layout.astro';
---
```
## The Preference Toggle
Once the blocking script is live, a theme toggle becomes trivial:
```javascript
function toggleTheme() {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
if (isDark) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
```
Click the button, the class flips, the CSS variables update instantly, and the preference persists. No re-renders, no hydration mess, no layout shift. The localStorage entry survives page reloads. System preference is respected on first visit.
We use Alpine for this on Aidxn sites — the toggle lives in the header:
```html
```
The $store.theme.isDark is a reactive Alpine store that tracks the current theme. When the class toggles, Alpine reactivity picks it up and the icon swaps.
## Six FAQs
**Q: Should I use prefers-color-scheme or localStorage?**
A: Both. System preference is the default for new users — it respects their OS setting. localStorage overrides it if the user explicitly chose a theme. Fallback order: stored preference → system preference → light.
**Q: What if I only support dark mode or only light?**
A: Then you don't need a toggle. Just set the theme statically in the blocking script. If the site is dark-only, the inline script becomes a one-liner: document.documentElement.classList.add('dark').
**Q: Do CSS variables have browser support?**
A: Yes. All modern browsers (2019+) support CSS custom properties. If you need IE11, you're out of luck — but if you're supporting IE11 in 2026, you have bigger problems. Use a transpiler like PostCSS to fallback to hardcoded colors, but it's not worth the complexity.
**Q: Will the blocking script hurt performance?**
A: No. The script is ~100 bytes and runs in microseconds. The alternative — letting the flash happen and then running the theme script after hydration — is slower for users on dark mode because they see the wrong colors first. The blocking script trades imperceptible latency for a better UX.
**Q: How do I handle theme in SSR?**
A: The blocking script handles it client-side after hydration. For server-side rendering, you can't know the user's preference until they visit (unless you're reading cookies set on previous visits). Let the page render in light mode, then the script flips it before paint if needed. Some teams set a cookie on toggle and read it server-side — overkill for most cases.
**Q: Can I animate the theme transition?**
A: Yes. Add a CSS transition to your root variables: transition: background-color 0.2s, color 0.2s;. The variables will fade between light and dark over 200ms. Feels polished.
## The Verdict
Dark mode is table stakes. The CSS variable + Tailwind + blocking script pattern is production-proven and ships on every Aidxn project. It centralises your color system, eliminates scattered dark: classnames, prevents the white flash, and lets users override their system preference with a single click.
Start with the blocking inline script — it's the biggest UX win. Then centralise your colors as CSS variables. Use Tailwind's dark: variant sparingly, only for semantic token names like dark:text-gray-100. The real work happens in the variables.
Check out our full pricing page to see how we apply this pattern across client projects, or read our Tailwind 4 vs 3 deep-dive for more on theming infrastructure.
Dark mode done right feels invisible. That's the mark of a good implementation.