Summit Themes
Blog

Building a multi-theme toggle with Astro and Tailwind CSS v4

Dark mode is table stakes. But as soon as a client asks for a "forest green" option alongside the default, or you want a high-contrast mode, the dark/light binary breaks down. What you actually need is a multi-theme system: named color schemes that swap at runtime without a page reload and survive navigation.

Tailwind CSS v4 makes this cleaner than it has ever been. The CSS-first @theme inline pattern — combined with a data-theme attribute on the root element and a tiny inline script — gives you as many themes as you want with no additional JavaScript frameworks required. Here is how to build it end-to-end in an Astro project.

How the pieces fit together

Before touching code, it helps to understand the data flow:

  1. CSS custom properties (on :root and scoped selectors) hold the raw color values for each theme.
  2. @theme inline maps those raw properties into Tailwind's token namespace, so utilities like bg-surface or text-brand resolve through the right variable chain at runtime.
  3. A data-theme attribute on <html> activates the right set of custom properties.
  4. An inline <script> in the document <head> reads localStorage and sets the attribute before the browser paints, eliminating flash.
  5. A small UI component (a button group or a <select>) writes to localStorage and updates the attribute on demand.

Step 1: Define your CSS token layers

Open (or create) your global stylesheet — typically src/styles/global.css. Import Tailwind, then define a two-layer token system: primitives (raw values) and semantics (intent-named aliases). The semantic layer is what Tailwind's utilities will reference.

/* src/styles/global.css */
@import "tailwindcss";

/* ─── Primitive palette ─── */
:root {
  --stone-50:  oklch(0.985 0.002 247.839);
  --stone-900: oklch(0.216 0.006 56.044);
  --sky-500:   oklch(0.623 0.214 259.815);
  --emerald-500: oklch(0.696 0.17 162.48);
  --amber-500: oklch(0.769 0.188 70.08);
}

/* ─── Semantic tokens: DEFAULT (light) theme ─── */
:root,
[data-theme="light"] {
  --color-surface:    var(--stone-50);
  --color-on-surface: var(--stone-900);
  --color-brand:      var(--sky-500);
}

/* ─── Semantic tokens: DARK theme ─── */
[data-theme="dark"] {
  --color-surface:    var(--stone-900);
  --color-on-surface: var(--stone-50);
  --color-brand:      var(--sky-500);
}

/* ─── Semantic tokens: FOREST theme ─── */
[data-theme="forest"] {
  --color-surface:    oklch(0.22 0.04 145);
  --color-on-surface: oklch(0.93 0.02 145);
  --color-brand:      var(--emerald-500);
}

/* ─── Semantic tokens: WARM theme ─── */
[data-theme="warm"] {
  --color-surface:    oklch(0.97 0.015 80);
  --color-on-surface: oklch(0.22 0.04 55);
  --color-brand:      var(--amber-500);
}

Notice the theme is activated purely by the data-theme attribute on an ancestor element — no class-toggling on every component, no JavaScript reaching into the DOM to swap individual styles.

Step 2: Wire tokens into Tailwind with @theme inline

Now tell Tailwind to generate utility classes from those semantic tokens. The inline keyword is important: without it, Tailwind would emit var(--color-surface) into the utility, which would resolve against the wrong scope. With inline, it emits the underlying variable name directly, so the value always resolves against the element that has data-theme.

/* continued in global.css, after the token blocks above */

@theme inline {
  --color-surface:    var(--color-surface);
  --color-on-surface: var(--color-on-surface);
  --color-brand:      var(--color-brand);
}

With this in place, you can write markup like:

<div class="bg-surface text-on-surface">
  <button class="bg-brand text-on-surface">Get started</button>
</div>

Swap data-theme on the root and every bg-surface, text-on-surface, and bg-brand in the document updates instantly — no re-renders, no style recalculation beyond variable resolution.

Step 3: Prevent flash with an inline head script

The biggest pitfall with any theme system is the flash of the wrong theme on page load. The browser parses CSS and renders a frame before your JavaScript runs — so if your theme-setting script is deferred or bundled, the user sees the default theme flash before the saved preference kicks in.

The fix is an inline, non-deferred script placed before any content in <head>. In Astro, use the is:inline directive to prevent bundling:

<!-- src/layouts/BaseLayout.astro -->
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <script is:inline>
    (function () {
      var THEMES = ['light', 'dark', 'forest', 'warm'];
      var DEFAULT = 'light';

      function applyTheme() {
        var saved = localStorage.getItem('theme');
        var theme = THEMES.includes(saved) ? saved : DEFAULT;
        document.documentElement.setAttribute('data-theme', theme);
      }

      // Run immediately on first load
      applyTheme();

      // Re-run after every Astro view-transition swap
      // so the attribute is not lost when the <html> element is replaced
      document.addEventListener('astro:after-swap', applyTheme);
    })();
  </script>

  <link rel="stylesheet" href="/src/styles/global.css" />
  <title>{title}</title>
</head>

The astro:after-swap listener is critical if you use Astro's ClientRouter (view transitions). During a client-side navigation, Astro replaces document.documentElement, which strips any attributes you set — including data-theme. The event fires immediately after the swap and before the browser paints the new page, so the theme is restored before any content is visible.

Step 4: Build the toggle component

Now build the UI. A simple set of buttons works well; a <select> is fine for more than four themes. Here is a plain Astro component with no framework dependency:

<!-- src/components/ThemeToggle.astro -->
<div role="group" aria-label="Choose color theme">
  <button data-theme-target="light"  aria-pressed="false">Light</button>
  <button data-theme-target="dark"   aria-pressed="false">Dark</button>
  <button data-theme-target="forest" aria-pressed="false">Forest</button>
  <button data-theme-target="warm"   aria-pressed="false">Warm</button>
</div>

<script>
  const buttons = document.querySelectorAll('[data-theme-target]');

  function syncButtons(theme) {
    buttons.forEach((btn) => {
      const isActive = btn.getAttribute('data-theme-target') === theme;
      btn.setAttribute('aria-pressed', String(isActive));
    });
  }

  function setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
    syncButtons(theme);
  }

  // Sync button state on load
  syncButtons(
    document.documentElement.getAttribute('data-theme') ?? 'light'
  );

  buttons.forEach((btn) => {
    btn.addEventListener('click', () => {
      setTheme(btn.getAttribute('data-theme-target'));
    });
  });
</script>

The aria-pressed attribute gives keyboard and screen-reader users accurate state without extra ARIA roles. Style the active button using the [aria-pressed="true"] attribute selector in your CSS — this keeps the visual and semantic state in sync automatically.

Step 5: Styling the toggle itself with Tailwind

Because your theme tokens are already in Tailwind's namespace, the toggle buttons can use the same utilities as the rest of your UI:

<button
  data-theme-target="forest"
  class="px-3 py-1.5 rounded-md text-sm
         bg-surface text-on-surface border border-on-surface/20
         aria-pressed:bg-brand aria-pressed:text-surface
         transition-colors"
>
  Forest
</button>

The aria-pressed: variant is available in Tailwind v4 out of the box as an ARIA state variant. When the script sets aria-pressed="true", Tailwind's generated styles kick in automatically — no extra JavaScript class manipulation needed.

Step 6: Extend to as many themes as you want

Adding a fifth theme is three steps:

  1. Add a [data-theme="ocean"] block to your CSS with the semantic token overrides.
  2. Add 'ocean' to the THEMES array in the inline head script.
  3. Add a <button data-theme-target="ocean"> in the toggle component.

No changes to any Tailwind configuration. No new utility classes. No component rewrites. The token layer absorbs the new theme completely.

A note on system preference

If you want to honour the user's OS preference as the default (rather than always defaulting to light), update the inline script's fallback logic:

var saved = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = THEMES.includes(saved)
  ? saved
  : prefersDark ? 'dark' : 'light';

This runs before the first paint, so there is still no flash — the correct theme is set synchronously based on a condition the browser resolves instantly.

What you do not need

A few things that seem necessary but are not:

  • A JavaScript framework. Everything here is vanilla JS and Astro components. No React, Vue, or Svelte context is needed to share theme state — the data-theme attribute on <html> is global state.
  • @custom-variant dark. That directive is for the two-value dark/light toggle using Tailwind's dark: utility prefix. With a named multi-theme system, you do not use dark: variants at all — the token layer handles the swap transparently.
  • A separate CSS file per theme. One stylesheet with scoped custom property blocks is sufficient and avoids any network waterfall.
  • Server-side cookie reading. Unless you are doing SSR and need zero-flash on the very first server response, localStorage is sufficient and far simpler.

Wrapping up

The pattern here — primitives in :root, semantics scoped to [data-theme], @theme inline bridging to Tailwind utilities, and a synchronous inline script — scales gracefully from two themes to twenty without touching component markup. Each new theme is a CSS block and a button. The browser handles the rest.

If you are building this into a theme product, the semantic token names (--color-surface, --color-brand, --color-on-surface) are also a clean API for customers who want to add their own brand colors without reading your source. Worth documenting in an llms.txt so their AI tools can find the customization points without spelunking.