Web Dev
AstroJavaScriptTailwind

Killing the Theme Flash With One Inline Script

4 min read Chase Isley

This method uses DaisyUI’s theme configuration alongside Tailwind 4. It can be used with any CSS framework using the same approach.

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:

  1. The first visit ever — no cookie exists yet
  2. 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:inline tells 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-scheme fallback so a dark-mode user on their first visit doesn’t get a face full of white pixels.
  • The IIFE keeps m, html, and s out 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.

Keep up with the latest in tech

Get a weekly email with hand-picked resources, tutorials, and insights on modern web development directly in your inbox.

No spam, ever. Unsubscribe with one click.