TL;DR: Managing theme state across static HTML and interactive islands is a classic footgun.
By combining Nanostores for cross-framework state, CSS semantic tokens for perfect visual
parity, and a robust localStorage script, you can build a zero-FOUC, reactive theme engine in
Astro 6.
The Problem with Static Theming
Theming in a partial-hydration framework like Astro presents a unique challenge.
You have static HTML rendered on the server. You have interactive React islands hydrating on the client.
If you manage theme state purely in React, your static components (like navbars or footers) won’t react to changes.
If you manage it purely in CSS, your React components (like a complex 3D canvas or a Framer Motion toggle) can’t subscribe to the state.
The result? Flash of Unstyled Content (FOUC), mismatched states, and a fragile architecture.
We need a better way.
The Reactive Architecture
The solution requires three distinct layers:
- Persistence Layer: An inline, render-blocking script to read
localStorageand prevent FOUC. - State Layer: A reactive store (
nanostores) that bridges Astro and React. - Presentation Layer: Semantic CSS variables ensuring 100% visual parity.
Here is the architectural flow:
graph TD
A[Inline Script] -->|Reads localStorage| B(Apply 'dark'/'light' class to HTML)
B --> C[Astro Static HTML]
B --> D[React Islands]
D -->|Toggle Clicked| E(Nanostore Update)
E -->|Syncs| A
E -->|Updates DOM| B
1. The Persistence Layer (Zero FOUC)
To prevent the dreaded FOUC, we must apply the theme before the browser paints.
This means an inline \<script\> inside our Astro \<head\>.
<!-- src/layouts/MainLayout.astro -->
<head>
<script is:inline>
const theme = (() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
})();
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
window.localStorage.setItem('theme', theme);
</script>
</head>
Crucial detail: Notice is:inline. This tells Astro not to bundle the script, ensuring it runs synchronously and blocks rendering until the correct CSS class is applied to \<html\>.
2. The State Layer (Nanostores)
Now we need our React islands to read and update this state.
Enter Nanostores. It’s the standard for cross-framework state in Astro because it’s tiny and framework-agnostic.
// src/store/theme.ts
import { atom } from 'nanostores';
export const themeStore = atom<'light' | 'dark'>('dark');
export function toggleTheme() {
const current = themeStore.get();
const next = current === 'dark' ? 'light' : 'dark';
themeStore.set(next);
localStorage.setItem('theme', next);
if (next === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
By decoupling the state from the view layer, our React components simply subscribe to themeStore without managing the DOM manipulation themselves.
3. The Presentation Layer (Semantic Tokens)
If you are hardcoding text-gray-900 or bg-white in your components, you are doing it wrong.
Stop hardcoding colors.
Instead, use semantic design tokens mapped via CSS variables.
/* src/styles/global.css */
@layer base {
:root {
--bg-primary: 255 255 255;
--text-primary: 9 9 11;
--border-primary: 228 228 231;
}
.dark {
--bg-primary: 9 9 11;
--text-primary: 250 250 250;
--border-primary: 39 39 42;
}
}
Then, configure Tailwind to consume these tokens:
// tailwind.config.mjs
export default {
darkMode: 'class',
theme: {
extend: {
colors: {
background: 'rgb(var(--bg-primary) / <alpha-value>)',
foreground: 'rgb(var(--text-primary) / <alpha-value>)',
border: 'rgb(var(--border-primary) / <alpha-value>)',
},
},
},
};
Now, your Astro components use bg-background and text-foreground. When the dark class toggles on the \<html\> element, the entire site updates instantly. Zero JavaScript required for the static components to re-render.
The Payoff: React Islands
Because our architecture is decoupled, creating a premium React toggle island is trivial.
// src/components/react/ThemeToggle.tsx
import { useStore } from '@nanostores/react';
import { themeStore, toggleTheme } from '@/store/theme';
import { motion } from 'framer-motion';
export function ThemeToggle() {
const theme = useStore(themeStore);
return (
<button onClick={toggleTheme} className="p-2 rounded-full border border-border">
<motion.div initial={false} animate={{ rotate: theme === 'dark' ? 180 : 0 }}>
{theme === 'dark' ? <MoonIcon /> : <SunIcon />}
</motion.div>
</button>
);
}
Astro View Transitions Edge Case
If you are using Astro View Transitions (\<ViewTransitions /\>), your inline script won’t run on subsequent page loads because the \<head\> isn’t fully re-evaluated.
To fix this, you must hook into the astro:after-swap event to re-apply the theme state when the DOM updates.
<script>
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme') || 'dark';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
</script>
Summary
This architecture achieves 100% theme parity across static Astro components and React islands.
It eliminates FOUC. It avoids unnecessary React re-renders. It separates concerns entirely.
Build fast. Stay minimalist. Ship it.