Theming
Salty CSS themes are plain CSS custom properties scoped to a parent selector. You declare two (or more) value sets under defineVariables' conditional scope, flip an attribute on an ancestor element (usually <html>), and every consumer of those tokens updates instantly.
That means no theme provider, no React context, no <ThemeProvider> wrapper, no re-render on switch, and no flash on hydration (with the inline-script pattern lower on this page). The same mechanism powers dark mode, high-contrast modes, brand themes — anything you can name with an attribute.
1. Declare the themes
Group your themed tokens under conditional.theme (the group name is yours to pick; theme is conventional). Each child key is one mode, and each mode declares the same set of token names.
// /styles/themes.css.ts import { defineVariables } from "@salty-css/core/factories"; export const themes = defineVariables({ conditional: { theme: { dark: { background: "{colors.black}", altBackground: "{colors.altBlack}", terminalBackground: "{colors.terminalBlack}", color: "{colors.white}", altColor: "{colors.altWhite}", highlight: "{colors.highlight}", }, light: { background: "{colors.white}", altBackground: "{colors.altWhite}", terminalBackground: "{colors.terminalWhite}", color: "{colors.black}", altColor: "{colors.altBlack}", highlight: "{colors.highlight}", }, }, }, });
The tokens reference static variables defined elsewhere ({colors.black}, {colors.white}, …) so the palette stays in one place. You're free to inline raw values too.
2. Consume the tokens
Reference them with the {theme.xxx} path — the group name becomes the namespace:
import { styled } from "@salty-css/astro/styled"; export const Surface = styled("section", { base: { background: "{theme.background}", color: "{theme.color}", borderColor: "{theme.altBackground}", }, });
You can use the same tokens inside defineGlobalStyles, defineTemplates, className, the color() helper — anywhere a Salty style accepts a value.
3. Activate a theme
Set the matching attribute on an ancestor. The attribute name mirrors the group key (theme) and the value mirrors the mode key (dark or light):
<html data-theme="dark"> ... </html>
That's it — every element under <html data-theme="dark"> now reads the dark values. Switching to data-theme="light" flips them instantly. Salty CSS emits the underlying rules (roughly [data-theme="dark"] { ... }) for you; you only need to set the attribute.
4. Wire up a toggle
A theme toggle is plain DOM work — read/write the attribute. Here's a React version that persists the choice in localStorage and respects the user's OS setting on first load:
--- // /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.
Picking up the OS preference
The browser exposes the user's color-scheme preference via the prefers-color-scheme media query. Salty's fluent media builder gives you media.dark and media.light for this — no manual string interpolation.
Define named queries you can reuse in styles:
// /styles/media.css.ts import { defineMediaQuery } from "@salty-css/astro/config"; export const prefersDark = defineMediaQuery((media) => media.dark); export const prefersLight = defineMediaQuery((media) => media.light);
Then choose how to honour the preference. There are two viable patterns and they aren't mutually exclusive:
Honour the OS preference, no user toggle. Apply data-theme="dark" once on <html> when matchMedia('(prefers-color-scheme: dark)') matches, and subscribe to its change event so the page tracks the OS:
// /components/auto-theme.ts "use client"; import { useEffect } from "react"; export function AutoTheme() { useEffect(() => { const query = window.matchMedia("(prefers-color-scheme: dark)"); const apply = (matches: boolean) => { document.documentElement.dataset.theme = matches ? "dark" : "light"; }; apply(query.matches); query.addEventListener("change", (event) => apply(event.matches)); return () => query.removeEventListener("change", apply as any); }, []); return null; }
Honour OS preference until the user overrides it. This is what the toggle snippet above does: read localStorage first, fall back to matchMedia if nothing is stored, and write to localStorage on every toggle. Combine that with the inline pre-hydration script (next section) for a no-flash experience.
If you'd rather use the named query in styles directly, both prefersDark and prefersLight are valid keys in any style object — "@prefersDark": { ... } works inline.
Animations that respect reduced motion
Pair theming with the reducedMotion media query so users who've requested less motion don't get a jarring transition when the theme flips:
import { styled } from "@salty-css/astro/styled"; export const Surface = styled("section", { base: { background: "{theme.background}", color: "{theme.color}", transition: "background 200ms ease, color 200ms ease", "@media (prefers-reduced-motion: reduce)": { transition: "none", }, }, });
Deriving shades from themed tokens
The color() helper accepts token references, so you can derive hover, focus, and disabled shades from a single themed token instead of declaring every variation manually:
import { styled } from "@salty-css/astro/styled"; import { color } from "@salty-css/core/helpers"; export const Button = styled("button", { base: { background: "{theme.background}", color: "{theme.color}", "&:hover": { background: color("{theme.background}").lighten(0.05), }, "&:disabled": { color: color("{theme.color}").alpha(0.5), }, }, });
Note: color() works with static token references. For values that already change at runtime (conditional or responsive tokens), the derivation is computed at build time against the value's compile-time form — useful for tinting a themed color, but be mindful that the derivation isn't recomputed when the theme flips. For dynamic, runtime-derived shades, declare the variants explicitly in your conditional group.
Multiple, independent groups
Conditional groups are independent. Add a density group alongside theme and toggle the two attributes separately:
defineVariables({ conditional: { theme: { dark: { background: "#0a0a0a" }, light: { background: "#fafafa" }, }, density: { compact: { gap: "8px" }, spacious: { gap: "24px" }, }, }, });
<html data-theme="dark" data-density="compact"> ... </html>
The two attributes compose freely — four combinations from two groups, with no extra config.
Avoiding the flash on first paint
When you defer the theme decision to client-side JS, users can see a brief flash of the default theme before the toggle script runs. The fix is to apply the attribute as early as possible. In Next.js App Router, render a small inline script in layout.tsx that reads localStorage and sets the attribute on <html> before the body hydrates. The snippet above includes that pattern.
See also
- Variables — the foundation that
conditionalis part of. - Color function — derive shades from themed tokens.
- Media queries —
prefers-color-schemeandprefers-reduced-motion.