Skip to content
A Astro Rocket
astro-rocket dark-mode design-system ux tutorial

System, Light, Dark — How Astro Rocket's Colour-Mode System Works

A 3-state colour-mode system with no flash, live OS-preference tracking, and a pill dropdown that respects what the user actually picked. Here is how it is built.

H

Hans Martens

3 min read

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:inline stops Astro from bundling and deferring the script. It must be inline.
  • The try / catch is not paranoia — Safari in private mode throws on localStorage.getItem. Without the catch, the whole script crashes and the page renders bare.
  • The window.__themeListenersInit guard 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-swap handlers 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:

SystemCurrently 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.

Back to Blog
Share:

Related Posts

How Astro Rocket's Design System Works — Tokens, Colors, and Dark Mode

Astro Rocket uses a three-tier token architecture with OKLCH colors. Change one value and the entire site updates. Here's how it works and how to make it yours.

H Hans Martens
2 min read
astro-rocket design-system tailwind customization tutorial

Going Multilingual: Native i18n in Astro Rocket 1.3.0

How the new opt-in i18n system works, what it adds when you turn it on, and the exact steps to ship your site in English plus a second language.

H Hans Martens
2 min read
astro-rocket i18n internationalization tutorial v1-3-0

Animations in Astro Rocket — How the Premium Motion Design Works

A deep dive into every animation layer in Astro Rocket: the spring-curve hero entrance, staggered cascades, scroll-reveal, and how it all stays smooth without a JS animation library.

H Hans Martens
2 min read
astro-rocket animation css ux performance

Follow along

Stay in the loop — new articles, thoughts, and updates.