Version 0.2.0 released! Check out the release notes from GitHub Releases

What Theming Means in Salty CSS — From Brand Color Schemes to a Switchable Layer of Native CSS Variables

Theming

"Theming" is a versatile word — it carries different meanings depending on who's using it, and most of them are fair. When some libraries and developers say it, they might mean a whole range of things: the entire design system, or simply how tokens get used across it. A good chunk of the time, though, what's actually being described is the layer I'd call variables — CSS custom properties. (I'll reach for "tokens" myself now and then, but I lean on the closer technical term.) That layer is real and useful, but on its own it isn't theming the way I think about it; it's the raw material theming is built from. (Variables have their own page: Variables.)

When I say theming, I'm almost always talking about color — specifically, color combinations. It shows up in two related forms depending on the context: a design idea and a technical structure. Really two views of the same thing.

Theming as a design idea

At its simplest, a theme is the light and dark versions of your components. (Automatic light/dark — the kind that follows the operating system's preference — is related to theming, but it isn't a one-to-one match for it; that distinction matters technically, and I come back to it below.)

It goes further than light and dark, though. Theming is also how you bring a brand's own colors into a combination. Instead of a very light grey background with black text, a theme might be a blue or green background with text chosen to keep enough contrast that it meets whatever accessibility standard the brand or client holds the project to. The theme is the scheme — the deliberate pairing of surfaces and the colors that have to stay legible on top of them.

Worth naming what's usually not in scope: type. A theme might change font families or other visual details, but most of the time it doesn't — plenty of sites keep their fonts theme-agnostic and swap only colors. By theme-agnostic I mean a value that stays the same no matter which theme is active: it doesn't take part in the switch at all. I mention it so you don't over-build the idea into something heavier than it needs to be.

Theming in the technical sense

Technically, theming is a mapping. You take the colors actually in use — which are themselves best treated as theme-agnostic — and map them onto a new mental layer that's about usage, not value.

The colors in use are best defined as static: the literal light grey, the black, the blue, the green, each pinned to a fixed token that never moves. Theming then puts those static colors to work in different contexts, switched from a single root "switch." Concretely, you define usage-contextual variables — theme-bg-color, theme-text-color, even button-primary-hover-color — and have each of them resolve to one of the static brand tokens, or a variation of one (whether as another variable or a hard-typed hex). In Salty, that mapping is what the conditional scope is for: the token name stays the same while its value flips based on an ancestor selector.

Atoms and molecules

The way I think about it: the static brand colors are atoms, and the themed values are molecules.

The atoms are the fixed palette — they don't move. The molecules are the contextual, named theme values that combine atoms into something usable in a specific role. You build molecules out of atoms; you don't reach for an atom directly when you're styling a component.

Atoms first — the palette, defined 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",
    },
  },
});

Molecules next — contextual roles that point at the atoms and flip by context:

// /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}",
        buttonPrimaryHover: "{colors.brand.blue}",
      },
      brand: {
        bg: "{colors.brand.green}",
        text: "{colors.grey.light}",
        buttonPrimaryHover: "{colors.brand.blue}",
      },
    },
  },
});

Same token names in both schemes, different atoms behind them. That's the whole trick.

Building with the molecular layer

Once the molecules exist, that's the layer you build components against. A component should read {theme.bg} and {theme.text} — never the raw {colors.brand.green}. That single indirection is what lets the same component turn into a different color scheme without being touched, either by manual selection (a toggle that sets an attribute) or by an automatic light/dark snippet.

import { styled } from "@salty-css/react/styled";

export const Section = styled("section", {
  base: {
    background: "{theme.bg}",
    color: "{theme.text}",
  },
});

Flip the scheme by setting the switch on an ancestor — and because it's just markup, schemes nest:

<main data-theme="light">
  <section data-theme="brand">...</section>
</main>

conditional vs responsive

One distinction is worth getting right early, because conditional and responsive are not interchangeable. Which one you want depends on whether the switch is manual or automatic.

  • conditional assumes a data attribute (or class). The value flips when an ancestor selector matches — exactly what you want for a user-driven toggle, or for markup that deliberately sets a scheme.
  • responsive expects a media query. The value swaps when the query matches, with no attribute and no JavaScript in the loop at all. So for genuinely automatic light/dark — the kind that just follows the operating system's prefers-color-scheme — reach for responsive keyed to that media query rather than conditional. The shape mirrors what you already saw, with a media-query key instead of a selector group:
// auto light/dark — no attribute, no JS
export default defineVariables({
  responsive: {
    base: { bg: "{colors.grey.light}", text: "{colors.black}" },
    "@darkScheme": { bg: "{colors.black}", text: "{colors.grey.light}" },
  },
});

(@darkScheme here is a media query you'd register with defineMediaQuery — the responsive keys are names you define, not magic strings.) The rule of thumb: conditional is for "the user or the markup chose this"; responsive is for "the environment decided."

For the more involved cases you can combine the two — an automatic default that a manual toggle is still able to override. That gets genuinely fiddly, so I'll leave it un-exampled here rather than pretend it's simple.

How Salty resolves this — the technical story

Everything above is authored in TypeScript and compiled away. When the compiler processes your config, the themes are emitted as native CSS custom properties and written to the global stylesheet — so your tokens are available before any component renders, and everything else in your styles is authored on the assumption that they're already there.

Because resolution lives entirely in the browser's CSS engine, flipping a theme is just updating an attribute:

document.documentElement.dataset.theme = "brand";

This triggers zero framework re-renders. The attribute changes, and the browser repaints the affected elements through the native variable cascade. (To be precise: mutating the attribute still triggers the browser's normal style recalculation for the affected elements — that's unavoidable and cheap.)

The provider-less part

This is where the React-specific side of the story comes in. It's a consequence of the model, not the headline — but it's a consequence people feel sharply, so it's worth spelling out.

If a styling library needs a <ThemeProvider> at the root of the app, that boundary has to be a client component. And because Server Components can't consume React Context, theming that way tends to drag a "use client" directive across large portions of an app, pushing rendering work back to the browser and giving up much of what a server-first architecture buys you. Context-based theming also asks React to track state: toggling light to dark updates the provider, which cascades a re-render across every styled component in the tree.

Salty doesn't have a provider to place, so there's nothing forcing that boundary. A theme is an attribute plus some CSS variables, and Server Components are perfectly happy with both. This reflects how Salty likes to work in general: when the platform can solve a problem natively, take a step back from the JavaScript state machinery and let it.

Framework-agnostic by the same mechanism

Because the switch is an attribute on <html> and the variables cascade down the DOM, the model doesn't care which framework rendered a given node — which is a real help for Astro's islands architecture. A page might be static HTML plus a React island plus a Vue island; with a React-specific provider, your tokens can't easily bridge to the Vue components or the raw .astro markup. With Salty, the data-theme attribute sits on <html> and the custom properties cascade through every island, so a React component and a plain Astro component can both reference var(--theme-bg) and get the right value at the same time, with no framework-specific bridge. And if a component is purely static, its styles resolve during SSR and the client runtime stays out of the browser payload entirely.

Preventing the flash (FOUC)

Because themes resolve from an HTML attribute, there are really two things to get right before the browser's first paint. The first is the attribute itself: the correct data-theme has to be present before paint, or the page renders in the wrong scheme for a frame. The upside of the attribute approach is that it sidesteps the classic CSS-in-JS flash — the swap is native, so you're not waiting on JavaScript to run and re-style the tree. Getting the attribute there in time comes down to your setup; three ways, depending on it.

  1. Zero-JS (pure CSS). If you don't need a user-facing toggle and just want to respect the OS preference, skip attributes entirely and use responsive tokens keyed to prefers-color-scheme. The browser resolves the preference itself before first paint, so there's no flash to prevent and no JavaScript runs.
  2. Server-side (best practice for a toggle). Store the preference in a cookie or database. Because cookies ride along with the initial request, your framework can read it and write the right data-theme into the HTML before it leaves the server — the correct scheme is in the markup from the first byte.
  3. Pre-hydration inline script. For a statically generated site or a pure SPA with no SSR, a small synchronous <script> in the <head> can read localStorage and set the attribute before the body parses. This is the one case where a sliver of blocking JavaScript runs — the trade you make to land the attribute ahead of first paint.

The other half: the stylesheet itself

The attribute is only half of it. It selects against rules that live in your CSS — and if that CSS hasn't arrived and applied by first paint, you get a flash of unstyled content regardless of which attribute is set. This isn't specific to theming, or to Salty; it's the original meaning of FOUC, and any late-loading stylesheet is prone to it.

The fixes are the generally accepted ones. Keep the stylesheet render-blocking in the <head> rather than loading it async or deferred, so the browser has the rules before it paints. For the styles that matter most above the fold, inline the critical CSS straight into the document head so it ships with the markup and there's no extra request to wait on. Preloading the stylesheet helps when it's a separate file you can't inline. Salty emitting your variables and themes to a single global stylesheet makes this easier to reason about — there's one well-known place the theme tokens live — but it doesn't excuse you from making sure that file is actually applied before paint.

Independent theme axes

Nothing here restricts you to a binary light/dark. Each conditional group is its own axis, set by its own attribute, and the groups resolve independently — so you can layer concerns that have nothing to do with each other. A common pair is a color theme and a layout density.

Define them as separate groups in the same config:

// /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}" },
    },
    density: {
      comfortable: { gap: "16px", controlHeight: "44px" },
      compact: { gap: "8px", controlHeight: "32px" },
    },
  },
});

Set each axis with its own attribute, and a single component can read from both at once:

styled("button", {
  base: {
    background: "{theme.bg}",
    color: "{theme.text}",
    height: "{density.controlHeight}",
  },
});
<html data-theme="dark" data-density="compact">
  ...
</html>

The point is that you never define the combinations. There's no dark-compact theme — there's a dark value on the color axis and a compact value on the density axis, and the browser resolves each one wherever it's used. Two axes with two values each cover all four combinations without you writing a single one of them out. That only holds as long as the groups stay orthogonal, so keep them that way — a theme group and a density group that toggle independently — rather than multiplexing unrelated concerns into one.

The build-time color boundary

Salty includes a build-time color() function for manipulating values — color('#000').alpha(0.5), and so on. Under the hood it uses the third-party color library as a build-time dependency only, so nothing extra ships to the browser.

The common mistake is trying to feed a themed variable into it. color() runs during compilation, so it can only transform values that are knowable then — raw colors and static token references. Hand it a conditional or responsive token and there's nothing to work with: the value doesn't exist yet at build time, so it passes straight through unchanged. color('{theme.bg}').darken(0.1) doesn't darken anything, because {theme.bg} is a runtime variable that swaps per scheme.

The fix is to derive from the static atom — which color() can see at build time — and store the result as a molecule. Rather than darkening on the fly inside a component, compute the hover shade once in your theme config and let the token carry it:

import { defineVariables } from "@salty-css/core/factories";
import { color } from "@salty-css/core/helpers";

export const themes = defineVariables({
  conditional: {
    theme: {
      light: {
        buttonBase: "{colors.brand.blue}",
        buttonHover: color("{colors.brand.blue}").darken(0.1),
      },
      dark: {
        buttonBase: "{colors.brand.blue}",
        buttonHover: color("{colors.brand.blue}").lighten(0.1),
      },
    },
  },
});

The shade is computed at build time from a static atom and baked into the themed token — so the hover still flips with the scheme, but nothing has to derive a color in the browser.

When the value is only known at request time

Pre-planned molecules cover the design system you control. Occasionally a value genuinely isn't known until a request arrives — a per-tenant brand color pulled from a database or CMS. That's what defineRuntime is for: it takes a style object and emits scoped CSS for it on the server, inline next to the element, with no client styling runtime. Because it uses the same parser as styled(), it isn't limited to flat variables — it supports full nesting and media queries too (scoping a request-time value to a child element only on mobile, for instance, is something a bare CSS variable can't express). The per-tenant brand color recipe walks through the whole pattern.

Theming components in practice

Once the atoms and molecules exist, most theming mistakes happen one level down — in how you reach for those tokens while actually building components. This is the short, practical version rather than an exhaustive checklist; three habits cover the bulk of it.

Consume molecules, not the design spec

The most common way to get theming wrong is to wire a component straight to the colors in the design spec. A Figma file hands you "brand blue," so the component gets {colors.brand.blue} — and now that value is frozen no matter which theme is active. The component looks right in the one scheme it was drawn in and falls apart in every other. Static atoms are for defining themes, not for consuming in components; a component should almost always read a molecule like {theme.bg}. Part of the job is translation: a design you're handed isn't automatically a valid theme. Turning "here are the colors" into "here's which token plays which role in each scheme" is a real step, not a formality, and it's where most of the theming work actually lives.

Have enough molecules — but don't lift the whole palette

The opposite failure is not having enough theme-level variables to build against. You reach for a token, it isn't there, and you fall back to an atom — straight back into the first pitfall. The tempting overcorrection is to hoist your entire palette up to the theme level: theme.bg.100 through theme.bg.900. I wouldn't. A full numbered palette at the theme level is genuinely useful — which is exactly why it tempts — but it's heavy, and it bloats every scheme you have to maintain. The middle ground is to add molecules where they earn their place: a bgAlt for a secondary surface, the odd in-between variation, and usage-based names like buttonPrimaryHover where a specific role really does recur. Enough to build comfortably, not so many that each new theme becomes a chore.

Some components shouldn't be themed at all

And the one that feels almost too obvious to say: some components just won't theme cleanly, and that's fine. Picture a section with a background image and text on top. The readable move is a small dark gradient behind the text — transparent to black at some alpha — with the text in white. That contrast guarantee shouldn't flex with the scheme; if it did, a light theme could swap the gradient out and leave white text sitting on a bright photo. So use static values here, on purpose — routing this through the theme would risk looking horrible. Knowing when not to send something through the theme is part of theming well.


Where to go next