This method uses DaisyUI’s theme configuration alongside Tailwind 4. It can be used with any CSS framework using the same approach.
Step one: If SSR, grab it from the cookie
Astro gives you cookies on Astro.cookies in any .astro file. Astro.cookies only works if you’re using SSR. Skip if you’re using a static build.
---
const theme = Astro.cookies.get("theme");
---
<html lang="en" data-theme={theme}>
That’s it for the SSR side. If the user has a cookie, the very first byte of HTML the browser sees already has the right data-theme attribute. Tailwind/daisyUI does the rest.
Step two: the inline script for everyone else
SSR handles returning visitors. But there are still two cases where the cookie isn’t there:
- The first visit ever — no cookie exists yet
- Any cached HTML that was generated without the user’s cookie context
For both, I want to honor prefers-color-scheme instead of flashing whatever the default happens to be. The trick is doing it before the first paint, which means a synchronous, blocking, inline script in the <head>. Astro has is:inline for exactly this:
<script is:inline>
(function () {
const m = document.cookie.match(/(?:^|; )theme=(light|dark)/);
const html = document.documentElement;
const s = (t) => html.setAttribute("data-theme", t);
if (m) {
return s(m[1]);
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return s("dark");
}
s("light");
})();
</script>
A few things worth pointing out:
is:inlinetells Astro not to bundle, hash, or defer this script. It runs exactly where you put it, the moment the parser hits it. That’s what makes it pre-paint.- Cookie regex, not
document.cookie.split, regex is fine for one key. prefers-color-schemefallback so a dark-mode user on their first visit doesn’t get a face full of white pixels.- The IIFE keeps
m,html, andsout of the global scope.
It’s eleven lines, it runs in well under a millisecond, and it makes the flash impossible because the attribute is set before the browser has rendered anything.
Step three: the toggle itself
The toggle handler does two things — flip the attribute on <html> so the page updates instantly, and write a cookie so the next request remembers:
export function initTheme() {
const themeToggle = document.querySelectorAll(".theme-toggle");
themeToggle?.forEach((toggle) => {
toggle?.addEventListener("click", () => {
const isLocal = window.location.hostname === "localhost";
const secure = isLocal ? "" : ";Secure";
const html = document.documentElement;
const current = html.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
html.setAttribute("data-theme", next);
document.cookie = `theme=${next};path=/;max-age=31536000;SameSite=Lax${secure}`;
});
});
}
No fetch, no API route, no round trip. Just set the attribute, write the cookie, done. The Secure flag gets dropped on localhost because Safari refuses to set Secure cookies on http://.
max-age=31536000 is one year. SameSite=Lax is the sensible default — the cookie travels on top-level navigations but not on cross-site embeds, which is exactly what you want for a UI preference.
Why I didn’t use an API route
My first draft posted to /api/theme and let the server set the cookie. It worked, but it added a network round trip to every toggle click for no benefit — the browser can write its own cookies, and there’s no validation or business logic that needs to happen server-side. Cookies set client-side are read by the server on the next request just fine. The API route was ceremony, not architecture.
The shape of the whole thing
Counting generously: eleven lines of inline script, one line of SSR, an event listener. That’s the entire system. No nanostore syncing, no effects, no dark-mode provider. The browser already has the primitive — a cookie that both sides can see — and once you’re using it, most of the usual machinery just falls away.