Most sites get dark mode subtly wrong. Either they treat it as a binary toggle and forget the OS exists, or they respect the OS and forget the user might want to override it. The fix is small but the implementation is full of edge cases — flashes on first paint, stale state across view transitions, listeners that double-bind, OS flips that fail to propagate.
Astro Rocket ships a 3-state colour-mode system — System / Light / Dark — that resolves all of those. The user picks their preference, the page never flashes the wrong theme, and ‘System’ tracks the operating system live. This post walks through how it is built and how the header pill exposes it.
The state contract
Three pieces of state, each with one job:
localStorage.theme ∈ {'system','light','dark'} ← user's choice
<html data-theme-mode="…"> mirrors the saved mode ← drives the trigger icon
<html>.dark resolved appearance ← what Tailwind keys off
The keys are intentional. localStorage (not sessionStorage) means the choice survives reloads and new tabs. data-theme-mode is separate from the resolved .dark class because the trigger icon needs to know what the user picked — a sun for ‘light’, a moon for ‘dark’, a monitor for ‘system’ — even when the resolved appearance under ‘system’ happens to be dark. Conflating the two would mean the System icon disappears the moment the OS is dark.
Resolving the appearance is a one-liner:
const isDark =
mode === 'dark' ||
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
That’s the entire decision tree. ‘System’ delegates to the OS, the other two override it.
The no-flash bootstrap
The hardest part of any theme system is not the toggle — it is making sure the right theme is on screen before the first paint. A <script> placed in <head> runs synchronously after the <html> tag is parsed but before the browser paints anything from <body>. That window is where the bootstrap lives:
<script is:inline>
(function () {
const VALID_MODES = ['system', 'light', 'dark'];
function getMode() {
try {
const stored = localStorage.getItem('theme');
if (VALID_MODES.indexOf(stored) !== -1) return stored;
} catch { /* private mode / disabled storage */ }
return 'system';
}
function applyMode(el) {
const mode = getMode();
el.setAttribute('data-theme-mode', mode);
const isDark =
mode === 'dark' ||
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
el.classList.toggle('dark', isDark);
}
applyMode(document.documentElement);
if (!window.__themeListenersInit) {
window.__themeListenersInit = true;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
mql.addEventListener('change', () => {
if (getMode() === 'system') applyMode(document.documentElement);
});
document.addEventListener('astro:before-swap', (e) =>
applyMode(e.newDocument.documentElement));
document.addEventListener('astro:after-swap', () =>
applyMode(document.documentElement));
}
})();
</script>
A few details that matter:
is:inlinestops Astro from bundling and deferring the script. It must be inline.- The
try / catchis not paranoia — Safari in private mode throws onlocalStorage.getItem. Without the catch, the whole script crashes and the page renders bare. - The
window.__themeListenersInitguard ensures media-query and view-transition listeners attach exactly once across page navigations. Without it, every navigation would stack another listener and you would eventually hit hundreds of duplicate callbacks per OS-theme flip. - The media-query listener is the live-update mechanism. When the OS flips between dark and light while the user is on ‘system’, the page follows immediately. If the user is on ‘light’ or ‘dark’, the OS flip is ignored — that is the whole point of the override.
- The
astro:before-swap/after-swaphandlers re-apply the theme across view transitions. Without them, a navigation that swaps the document head can momentarily show the SSR default before the bootstrap re-runs.
The <html> tag carries seed defaults — class="dark" data-theme-mode="system" — so even visitors with JavaScript disabled satisfy the contract.
The pill UI
The header surfaces the colour-mode picker as a small pill — rounded-full border, an icon, a chevron — designed to sit alongside the colour-theme picker without visual noise. Two design decisions made it more interesting than it looks.
Icon switching is pure CSS
The pill renders all three icons at once and hides the inactive ones. The visible icon is selected by a CSS rule keyed off <html data-theme-mode>:
.ttg-trigger .ttg-icon { display: none; }
html[data-theme-mode='system'] .ttg-trigger .ttg-icon-system,
html[data-theme-mode='light'] .ttg-trigger .ttg-icon-light,
html[data-theme-mode='dark'] .ttg-trigger .ttg-icon-dark {
display: block;
}
html:not([data-theme-mode]) .ttg-trigger .ttg-icon-system { display: block; }
The fallback rule on the last line covers the brief moment before the bootstrap script runs — without it, a JS-disabled visitor would see an empty pill. With it, they see the System icon by default, which is the right fallback.
The win here is that the icon is correct from the very first frame. There is no JavaScript branch that decides which SVG to render; the browser does it from the attribute the bootstrap already set before paint.
The dropdown panel
Clicking the trigger opens a role="menu" panel anchored top-right. Inside it, three role="menuitemradio" rows — System, Light, Dark — each carrying a data-mode attribute. Selecting a row does three things:
localStorage.setItem('theme', next);
applyMode(next); // updates data-theme-mode + .dark
syncAllRows(next); // updates aria-checked on every row
The active row gets a secondary background, the matching checkmark becomes visible, and the panel closes. The chevron on the trigger rotates 180° when the panel opens; outside clicks and Escape close it.
The “Currently dark” sub-line
Under the System row sits a small sub-line:
System — Currently dark
That tiny piece of text is the most user-friendly part of the whole component. When ‘System’ is just a label, users have no way to know what it currently resolves to without inspecting the page. The sub-line says it explicitly.
It updates live via the same media-query listener:
const mql = window.matchMedia('(prefers-color-scheme: dark)');
mql.addEventListener('change', () => {
document.querySelectorAll('.ttg-system-sub').forEach((el) => {
el.textContent = osIsDark() ? 'Currently dark' : 'Currently light';
});
});
Open the dropdown, flip your OS theme from another window, and watch the sub-line change without closing the menu.
Two instances, one state
The pill is rendered twice — once in the desktop header (hidden below the md breakpoint) and once inside the mobile menu (hidden above md). Both instances share state because every mutation walks the document, not the local component:
function syncAllRows(mode) {
document.querySelectorAll('.ttg-row').forEach((row) => {
row.setAttribute('aria-checked', row.dataset.mode === mode ? 'true' : 'false');
});
}
Selecting Dark in the mobile menu instantly updates the desktop pill’s aria-checked state — and the icon-swap CSS does the rest.
The init function is idempotent — a dataset.ttgInit flag on each wrapper prevents double-binding when Astro re-runs the script on astro:after-swap. A separate window.__ttgGlobalInit flag does the same for the document-level listeners (outside-click, Escape, media-query). The result is that two component instances and any number of view transitions never accumulate duplicate handlers.
What the user actually sees
Open the site for the first time on a Mac in dark mode: the header pill shows the monitor icon, the page is dark. Open the dropdown: System is highlighted, with “Currently dark” beneath it. Pick Light: the page goes light, the icon turns into a sun, the choice is persisted to localStorage. Reload the tab: still light. Flip macOS to light mode: nothing changes — the user explicitly overrode. Pick System again: back to tracking the OS, sub-line says “Currently light”.
Every state transition is one attribute change away from the next. No flashes, no double listeners, no cases where the icon and the resolved theme disagree. The contract is small enough to keep entirely in your head, which is why it works.