→ The switchable layer on top of your variables — one attribute repaints an entire tree.
Theming
Most libraries call the whole token object "the theme." Salty splits it in two: variables are the raw values, theming is the switchable layer on top — the one attribute that repaints an entire tree without touching a component, and without a re-render. Flip that attribute on an ancestor and every themed color beneath it resolves to a different value through the browser's native variable cascade. It works the same in a Server Component, a client island, or plain .astro markup.
If you want the longer why — the design thinking behind it, and how it differs from raw CSS variables — the Theming concept page covers it. This page is the how: the mechanism, the APIs, and four worked examples you can build from.
How theming works, briefly
A quick vocabulary split, because Salty uses two words other libraries often merge into one:
- Variables are your raw design tokens — the literal colors, defined once and never moved.
defineVariableswrites them as CSS custom properties on:root. - Theming is a switchable layer built on top of variables. Same token names, different values behind them depending on context.
The mental model worth carrying through every example below: your fixed palette entries are atoms — colors.brand.blue, colors.black. The themed, role-named values are molecules — theme.bg, theme.text, theme.buttonPrimaryHover. Atoms are for defining themes; molecules are what you build components against. A component reads {theme.bg}, never the raw {colors.brand.blue} — that single layer of indirection is exactly what lets one component become a different color scheme without being touched.
Two scopes decide how a theme switches:
conditionalflips when an ancestor selector matches — adata-themeattribute or class. This is for switches the user or the markup chooses: a toggle, or a section deliberately set to a scheme.responsiveflips when a media query matches — no attribute, no JavaScript. This is for switches the environment decides, like the OSprefers-color-scheme.
Everything is authored in TypeScript in a .css.ts file and compiled away at build time. What ships is a static stylesheet of native custom properties — nothing theming-related runs in the browser except, when you add a toggle, the few lines that set an attribute.
One boundary to know up front, because it trips people: the build-time color() helper can only transform values it can see at build time — raw colors and static atoms. Hand it a themed molecule and it passes straight through unchanged, because that value doesn't exist yet when the compiler runs. Derive shades from atoms and store the result as a molecule; there's a worked version in Example 1.
Example 1 — Light and dark: the core mechanism
Start with what actually ships to the browser, then work backward to how you author it.
What the browser gets
Set data-theme on an ancestor and every themed custom property below it flips. Roughly, this is the CSS Salty compiles for a light/dark pair:
/* the atoms — your fixed palette, on :root */
:root {
--colors-grey-light: #f0f0f0;
--colors-black: #0a0a0a;
}
/* the molecules — same names, different atoms behind them */
[data-theme="light"] {
--theme-bg: var(--colors-grey-light);
--theme-text: var(--colors-black);
}
[data-theme="dark"] {
--theme-bg: var(--colors-black);
--theme-text: var(--colors-grey-light);
}
/* the component just reads the molecules — it never changes */
.section_hashed {
background: var(--theme-bg);
color: var(--theme-text);
}That's the entire trick, and it's why there's no re-render on switch: changing the attribute changes which [data-theme="…"] block wins, and the native cascade repaints. The component's own rule doesn't know or care which theme is active.
How you author it
Three files, three jobs. First the atoms — the fixed palette, as static variables:
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
colors: {
grey: { light: "#f0f0f0" },
black: "#0a0a0a",
brand: { blue: "#0070f3", green: "#1f9d55" },
},
});Then the molecules — contextual, role-named values, declared under the conditional scope. The group name (theme) is yours to pick; theme is just the convention. Every mode declares the same token names, each pointing at a different atom:
import { defineVariables } from "@salty-css/core/factories";
export const themes = defineVariables({
conditional: {
theme: {
light: { bg: "{colors.grey.light}", text: "{colors.black}" },
dark: { bg: "{colors.black}", text: "{colors.grey.light}" },
},
},
});Then consume the molecules with the {theme.xxx} path — the group name becomes the namespace. The same tokens work in styled, className, defineGlobalStyles, defineTemplates — anywhere a Salty value is accepted:
import { styled } from "@salty-css/react/styled";
export const Section = styled("section", {
base: {
background: "{theme.bg}",
color: "{theme.text}",
},
});Finally, activate a theme by setting the attribute on an ancestor — usually <html>:
<html data-theme="dark">
...
</html>That's the full loop: atoms → molecules → consume → activate. For a user-controlled toggle (persisting the choice, seeding from the OS, and avoiding a flash on first paint) the mechanism is the same one-line attribute set — the extra plumbing lives in a recipe rather than here, so this page stays about the model. See Recipes below.
Extending it
Because a theme is "a set of molecules keyed by one attribute value," extending is mechanical.
Add another scheme — a third mode alongside light and dark, same token names again:
conditional: {
theme: {
light: { bg: "{colors.grey.light}", text: "{colors.black}" },
dark: { bg: "{colors.black}", text: "{colors.grey.light}" },
brand: { bg: "{colors.brand.green}", text: "{colors.grey.light}" },
},
}data-theme="brand" now just works — no component changes.
Add theme-aware states. Interactive states stay in sync with the active theme if you reach for the same conditional tokens inside &:hover, &:disabled, and friends. Declare the extra molecules once per mode, then use them:
// molecules gain an alt surface + a hover shade, per mode
conditional: {
theme: {
light: {
bg: "{colors.grey.light}", text: "{colors.black}",
bgAlt: "#e4e4e4",
buttonHover: color("{colors.brand.blue}").darken(0.1),
},
dark: {
bg: "{colors.black}", text: "{colors.grey.light}",
bgAlt: "#1a1a1a",
buttonHover: color("{colors.brand.blue}").lighten(0.1),
},
},
}export const Button = styled("button", {
base: {
background: "{theme.bg}",
color: "{theme.text}",
"&:hover": { background: "{theme.buttonHover}" },
"&:disabled": { background: "{theme.bgAlt}", opacity: 0.5 },
},
});Note the color() call sits in the theme config, deriving the hover shade from a static atom ({colors.brand.blue}) at build time and baking the result into a molecule. That's the boundary from the intro in practice: you can't darken a themed molecule in the component (color("{theme.bg}") has nothing to work with at build time), but you can darken an atom here and let the molecule carry the result — so the hover still flips with the scheme.
One restraint worth stating, because it's the common overcorrection: add molecules where a role genuinely recurs (bgAlt, buttonHover), but don't hoist your entire palette to the theme level (theme.bg.100 … theme.bg.900). A full numbered palette per scheme is heavy, and every theme you add has to maintain all of it. Enough molecules to build comfortably; not so many that each new theme becomes a chore.
Example 2 — Many values per theme, and a bit of personality
Light and dark is the smallest case: two molecules, two modes. The mechanism doesn't cap there, and it's worth seeing it carry a lot of values, because that's where the atom/molecule split earns its keep — and because it's how you give people a page that feels like theirs.
Here's a small thing I care about: I'd love to see personal profiles and customizable pages — websites, for crying out loud — make a comeback. Not every corner of the web has to look like the same three SaaS templates. Letting someone pick a look for their own space is half the charm of the personal web, and it's a natural fit for theming: a profile "look" isn't a color or two, it's a whole coordinated set — page background, card surfaces, borders, text, links, an accent — that all has to move together and stay legible.
Model each named look as one mode in a conditional group. Nothing about the authoring changes from Example 1 — only the number of molecules does:
import { defineVariables } from "@salty-css/core/factories";
export const themes = defineVariables({
conditional: {
profileTheme: {
classic: {
pageBg: "{colors.grey.light}",
cardBg: "#ffffff",
cardBorder: "#d9d9d9",
text: "{colors.black}",
mutedText: "#5a5a5a",
link: "{colors.brand.blue}",
accent: "{colors.brand.blue}",
},
midnight: {
pageBg: "{colors.black}",
cardBg: "#141414",
cardBorder: "#2a2a2a",
text: "{colors.grey.light}",
mutedText: "#9a9a9a",
link: "#7ab8ff",
accent: "{colors.brand.green}",
},
sunset: {
pageBg: "#2b1a2e",
cardBg: "#3a2340",
cardBorder: "#54324f",
text: "#ffe9d6",
mutedText: "#c9a7b4",
link: "#ff9e6d",
accent: "#ff6b9d",
},
},
},
});Consume the molecules by role, exactly as before — the namespace is now {profileTheme.xxx} because that's the group name:
export const ProfileCard = styled("section", {
base: {
background: "{profileTheme.cardBg}",
borderColor: "{profileTheme.cardBorder}",
color: "{profileTheme.text}",
"& small": { color: "{profileTheme.mutedText}" },
"& a": { color: "{profileTheme.link}" },
},
});A person picks their look, you set one attribute on their page shell, and the whole profile changes:
<body data-profile-theme="sunset">
...
</body>Two habits carry over from the model and are worth repeating, because they're where richer themes go wrong:
- Consume molecules, not the design spec. When a design hands you "the accent is
#0070f3," the temptation is to write{colors.brand.blue}straight into the component. Do that and the value is frozen in one look. The translation step — deciding which token plays which role in each theme — is the actual work; a handed-over palette isn't automatically a valid theme. - Type is usually theme-agnostic. A look can swap font families, but most of the time it shouldn't — plenty of sites keep fonts fixed and switch only color. A theme-agnostic value is one that doesn't take part in the switch at all; leave it as a plain static variable and don't lift it into the group.
So "many values" isn't a different feature — it's the same conditional group with more molecules, plus more care about which real roles deserve a name. One scope note: this example is a set of named, designed looks a person chooses between. Letting someone pick a genuinely arbitrary color of their own — a value you can't know at build time — is a different story told elsewhere (see the links at the end of this page); it isn't part of the conditional mechanism on this page.
Example 3 — Themes within themes
Because activation is just an attribute in the markup, and every element reads the nearest ancestor that carries the group's attribute, themes nest for free. A light page can hold a dark card that holds a light tooltip — each region picks up its own values, no coordination required.
<main data-theme="light">
<article data-theme="dark">
<!-- everything here reads the dark molecules -->
<aside data-theme="light">
<!-- ...and this pocket flips back to light -->
</aside>
</article>
</main>No component in that tree changed. Each just reads {theme.bg} and resolves against whichever data-theme is closest above it.
This nesting is also how you scope a sub-theme to one component — a button that carries its own themable surface without disturbing the section around it. The clean way to model "a theme inside a theme" that's genuinely a different concern is a separate conditional group, resolved independently from the page theme:
conditional: {
theme: {
light: { bg: "{colors.grey.light}", text: "{colors.black}" },
dark: { bg: "{colors.black}", text: "{colors.grey.light}" },
},
buttonTheme: {
solid: { btnBg: "{colors.brand.blue}", btnText: "{colors.grey.light}" },
ghost: { btnBg: "transparent", btnText: "{colors.brand.blue}" },
inverse: { btnBg: "{colors.grey.light}", btnText: "{colors.black}" },
},
}export const Button = styled("button", {
base: { background: "{buttonTheme.btnBg}", color: "{buttonTheme.btnText}" },
});<section data-theme="dark">
<!-- reads the dark page theme -->
<button data-button-theme="inverse">Call to action</button>
</section>The button resolves data-button-theme from itself (or the nearest ancestor that sets it) and data-theme from the section — two axes, resolved independently. That independence is the point of using a separate group rather than baking button variants into the page theme: you never have to define the combinations (dark × inverse × …). The browser resolves each axis wherever it's used, so N page themes and M button themes cover N×M pairings without you writing a single one out — as long as the two groups stay orthogonal.
If the sub-theme is really the same concern as the page theme (a nested region that's just "light again"), don't add a group — nest data-theme as in the first snippet. Reach for a second group only when the axis is genuinely independent.
Example 4 — Automatic light and dark from the OS
The examples above all use conditional, because they're switches a user or the markup chooses. For a theme that should just follow the operating system — no toggle, no attribute to manage — reach for the responsive scope instead, keyed to a prefers-color-scheme media query. The browser resolves the preference itself before first paint, so there's no JavaScript in the loop and no flash to prevent.
First, name the media query:
import { defineMediaQuery } from "@salty-css/react/config";
export const prefersDark = defineMediaQuery((media) => media.dark);Then declare the same molecules you'd have put in conditional, but under responsive — a base set plus an override keyed to the query name. Only redeclare what changes:
import { defineVariables } from "@salty-css/core/factories";
export const themes = defineVariables({
responsive: {
base: {
theme: { bg: "{colors.grey.light}", text: "{colors.black}" },
},
"@prefersDark": {
theme: { bg: "{colors.black}", text: "{colors.grey.light}" },
},
},
});The component doesn't change at all — it still reads {theme.bg} and {theme.text}:
export const Section = styled("section", {
base: { background: "{theme.bg}", color: "{theme.text}" },
});That's the teaching point of putting this last: the consumption side is identical to Example 1. All that moved is where the molecules are defined — conditional (attribute-driven) versus responsive (media-query-driven). Pick the scope by asking who does the switching.
The tradeoff is the honest catch: with responsive alone there's no way to override the OS — no toggle, no data-theme="brand" sections. If you need a user override and an automatic default, that's conditional (for the override) layered over a responsive default, and combining the two cleanly gets genuinely fiddly — involved enough that it's better shown as a recipe than sketched here.