Version 0.4.0 now released! See the release notes on GitHub Releases

→ 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. defineVariables writes 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 atomscolors.brand.blue, colors.black. The themed, role-named values are moleculestheme.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:

  • conditional flips when an ancestor selector matches — a data-theme attribute or class. This is for switches the user or the markup chooses: a toggle, or a section deliberately set to a scheme.
  • responsive flips when a media query matches — no attribute, no JavaScript. This is for switches the environment decides, like the OS prefers-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:

saltygen/index.css
/* 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:

/styles/variables.css.ts
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:

/styles/themes.css.ts
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:

/components/section.css.ts
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>:

Example
<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:

Example
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:

Example
// 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),
    },
  },
}
Example
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.100theme.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:

/styles/themes.css.ts
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:

Example
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:

Example
<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.

Example
<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:

Example
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}" },
  },
}
Example
export const Button = styled("button", {
  base: { background: "{buttonTheme.btnBg}", color: "{buttonTheme.btnText}" },
});
Example
<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:

/styles/media.css.ts
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:

/styles/themes.css.ts
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}:

Example
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 definedconditional (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.