Two Themes Compiled Ahead of Time, Switched by a Single data-theme Attribute on the Root Element
A data-theme Toggle in React
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 React component
// /components/panel.css.ts import { styled } from "@salty-css/react/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:
// /components/theme-toggle.tsx "use client"; import { useEffect, useState } from "react"; type Theme = "dark" | "light"; const STORAGE_KEY = "theme"; const readInitial = (): Theme => { if (typeof window === "undefined") return "light"; const saved = window.localStorage.getItem(STORAGE_KEY) as Theme | null; if (saved === "dark" || saved === "light") return saved; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; export const ThemeToggle = () => { const [theme, setTheme] = useState<Theme>("light"); useEffect(() => { const initial = readInitial(); setTheme(initial); document.documentElement.dataset.theme = initial; }, []); const toggle = () => { const next: Theme = theme === "dark" ? "light" : "dark"; setTheme(next); document.documentElement.dataset.theme = next; window.localStorage.setItem(STORAGE_KEY, next); }; return ( <button type="button" onClick={toggle} aria-label="Toggle theme"> {theme === "dark" ? "☀ Light" : "☾ Dark"} </button> ); };
To avoid a flash of the wrong theme on first paint in Next.js, inline a tiny pre-hydration script in app/layout.tsx (or _document.tsx for the Pages Router) so the attribute is set before React hydrates:
// /app/layout.tsx const setThemeScript = ` try { var t = localStorage.getItem("theme"); if (t !== "dark" && t !== "light") { t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } document.documentElement.dataset.theme = t; } catch (e) {} `; export default function RootLayout({ children }) { return ( <html> <head> <script dangerouslySetInnerHTML= /> </head> <body>{children}</body> </html> ); }
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.