Version 0.1.0 just released! Check out the release notes from GitHub Releases

A data-theme Toggle for Astro Pages Where Both Themes Live in the Same Stylesheet at Build Time

A data-theme Toggle in Astro

Dark mode is the single most common ask for a CSS-in-JS library — and the place most of them ship runtime overhead. With Salty CSS, both themes are compiled into a single stylesheet keyed off a data-theme attribute on a root element. The "toggle" is just an attribute flip; no provider, no context, no extra bundle.

Declare the tokens once

Themes live as a conditional variable group — each leaf token resolves through var(--theme-…) and the variant set you pick wins via the data attribute selector on html.

// /styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";

export default defineVariables({
  theme: {
    base: {
      background: "white",
      color: "#111",
      highlight: "#0070f3",
    },
    "[data-theme=dark]": {
      background: "#0b0d12",
      color: "#f5f5f5",
      highlight: "#7aa7ff",
    },
  },
});

Use the tokens in any Astro component

// /components/panel.css.ts
import { styled } from "@salty-css/astro/styled";

export const Panel = styled("section", {
  base: {
    background: "{theme.background}",
    color: "{theme.color}",
    padding: "2rem",
    borderRadius: "0.5rem",
    transition: "background 0.2s ease, color 0.2s ease",
  },
});

Flip the attribute

The minimal "toggle" is a button that writes data-theme on documentElement. Persist it in localStorage, and inline a tiny pre-hydration script to avoid a flash of the wrong theme on first paint:

---
// /src/components/ThemeToggle.astro
---

<button id="theme-toggle" type="button" aria-label="Toggle theme">☾ / ☀</button>

<script is:inline>
  (() => {
    const STORAGE_KEY = "theme";
    const initial =
      localStorage.getItem(STORAGE_KEY) ||
      (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
    document.documentElement.dataset.theme = initial;

    document.getElementById("theme-toggle")?.addEventListener("click", () => {
      const next =
        document.documentElement.dataset.theme === "dark" ? "light" : "dark";
      document.documentElement.dataset.theme = next;
      localStorage.setItem(STORAGE_KEY, next);
    });
  })();
</script>

The is:inline directive ensures the script runs before the first paint, so users never see a flash of the wrong theme.

What ships to the browser

The compiler emits both theme branches into saltygen/index.css — one block of declarations under the base selector, one under [data-theme=dark]. The runtime cost of switching is zero JavaScript styling work: the browser re-resolves var(--theme-…) after the attribute changes. Nothing about Salty CSS is loaded on the client to make this work.

Gotchas

  • First-paint flash. Statically exported pages render with the default theme until JS runs. The pre-hydration script in the snippet above sets data-theme before React/Astro hydrates, so the correct theme is in place from the first frame.
  • System preference. prefers-color-scheme: dark is honoured by the read-from-storage logic in the snippet — the user's explicit choice always wins over the OS.
  • Server-rendered initial state. In the Next.js App Router, the root <html> is rendered on the server. You can't read localStorage there; render with no data-theme and let the inline script set it before paint.