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-themebefore React/Astro hydrates, so the correct theme is in place from the first frame. - System preference.
prefers-color-scheme: darkis 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 readlocalStoragethere; render with nodata-themeand let the inline script set it before paint.