Theme toggle
A dark mode toggle is usually where styling libraries start shipping runtime: a provider at the root, context, a re-render on every switch. With Salty CSS both themes are compiled into one stylesheet, keyed off a data- attribute. The toggle is an attribute flip — the browser does the rest through the native variable cascade.
Declare the tokens
The palette goes in as static atoms. The themed values are molecules in a conditional group: the same token names in both themes, different atoms behind them.
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
// Atoms — the fixed palette.
colors: {
white: "#ffffff",
ink: "#111111",
night: "#0b0d12",
mist: "#f5f5f5",
blue: { base: "#0070f3", light: "#7aa7ff" },
},
// Molecules — what components read, flipped by data-theme.
conditional: {
theme: {
light: {
bg: "{colors.white}",
text: "{colors.ink}",
accent: "{colors.blue.base}",
},
dark: {
bg: "{colors.night}",
text: "{colors.mist}",
accent: "{colors.blue.light}",
},
},
},
});Use them in a component
Any React component reads the molecules, never the atoms. That one indirection is what lets it follow the theme without being touched.
import { styled } from "@salty-css/react/styled";
export const Panel = styled("section", {
base: {
background: "{theme.bg}",
color: "{theme.text}",
borderLeft: "4px solid {theme.accent}",
padding: "2rem",
transition: "background 0.2s ease, color 0.2s ease",
},
});Flip the attribute
A conditional group has no base — with no data- on an ancestor, the {theme. tokens resolve to nothing. So the markup always ships a default, and a small inline script in the <head> swaps it for the saved choice or the OS preference before the first paint.
const themeScript = `
const saved = localStorage.getItem("theme");
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme = saved ?? (prefersDark ? "dark" : "light");
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// The script changes data-theme before hydration, so React is told not to warn.
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML=undefined />
</head>
<body>{children}</body>
</html>
);
}The toggle itself reads the current attribute, flips it and saves the choice. There is no theme state to keep in sync — the attribute is the state.
What ships to the browser
The compiler writes both themes into the global stylesheet at build time — one block per [data- value — and the component rule only ever reads the variables:
:root {
--colors-white: #ffffff;
--colors-night: #0b0d12;
/* …the rest of the palette */
}
[data-theme="light"] {
--theme-bg: var(--colors-white);
--theme-text: var(--colors-ink);
}
[data-theme="dark"] {
--theme-bg: var(--colors-night);
--theme-text: var(--colors-mist);
}
.panel_hashed {
background: var(--theme-bg);
color: var(--theme-text);
}Flipping the attribute changes which block wins, and the browser repaints through the cascade. No provider, no context, no re-render, and nothing from Salty CSS runs on the client to make it happen.
Gotchas
- First-paint flash. The script has to be inline and synchronous in the
<head>. Setting the theme in an effect or a deferred script runs after paint, and the page flashes the default theme first. - OS preference.
prefers-only seeds the first visit. Once the user picks a theme, the saved choice always wins over the OS.color- scheme - Server-rendered default. The server can’t read
localStorage, so it renders the default theme and the script corrects it on the client.suppressHydrationWarningon the root<html>keeps React quiet about the changed attribute.