→ Name a value once, reference it by path everywhere, and let the compiler check it.
Variables & Tokens
A design token in Salty is just a value you name once and reuse everywhere — a color, a spacing step, a font size — written in TypeScript and compiled into a real CSS custom property. You define it with defineVariables, reference it anywhere a style takes a value with {path.to.token}, and Salty checks the path for you at build time so a typo fails the compile instead of silently unstyling an element.
Two quick notes before the examples, because they set the mindset:
"Variables," "tokens," "custom properties" — mostly the same thing, and it's worth a word on why Salty says "variables." The naming is a genuine mess across the ecosystem: some libraries call the whole token object the theme, others say tokens, and those systems tend to be JavaScript-based — a runtime object you thread through a provider. Salty sits closer to vanilla CSS, where variable is an actual, spec-defined term for exactly this thing, so that's the word it uses. Call it staying out of the fight by being friends with the MDN spec — while keeping just enough distance, because the spec itself insists on calling them custom properties, which is nobody's idea of a fun word to type. 🧂 So: read "variable," "token," or "custom property" as the same mechanism throughout — Salty just picks the platform's word.
Almost all of it resolves at build time. defineVariables doesn't ship a token system to the browser — it writes plain CSS custom properties onto :root (or onto a conditional selector) in the static stylesheet. So the runtime cost is exactly that of hand-written CSS variables: nothing more. What you gain over hand-writing them is the TypeScript layer — autocomplete on the paths, and build-time validation that catches a mistyped token before it ever ships. If you want the deeper why behind that build-time line, the Compiler concept is the read.
One framing that connects this to the pages on either side: a token is what a value graduates into when it stops being a local convenience. A one-file const (see Dynamic Values, Example 1) is the right tool for a value one component uses. A token is the right tool when a value is shared, typed, and part of the system — one place to change it, one name everyone references. And a token is also the raw material Theming is built from: your fixed tokens are the atoms, theming adds a switchable layer on top. More on that in Example 3.
Example 1 — Static tokens: the core case
The everyday case. Define a value once; reference it by path everywhere. "Static" means it lands on :root and stays put — brand colors, fixed spacing, a type scale, anything that doesn't depend on breakpoint or context.
How you define it
Pass a plain object. Keys nest as deep as you like, and the nested path is the token's reference.
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
colors: {
grey: { light: "#f0f0f0" },
black: "#0a0a0a",
brand: { blue: "#0070f3", green: "#1f9d55" },
},
spacing: {
small: "8px",
medium: "16px",
large: "32px",
},
fontFamily: {
heading: "Inter, system-ui, sans-serif",
body: "Georgia, serif",
},
});How you use it
Reference a token with {path.to.token} from anywhere a Salty style object accepts a value — inside styled, className, defineGlobalStyles, defineTemplates, all the same:
import { styled } from "@salty-css/react/styled";
export const Card = styled("div", {
base: {
background: "{colors.brand.blue}",
color: "{colors.grey.light}",
padding: "{spacing.large}",
fontFamily: "{fontFamily.body}",
},
});That path isn't a loose string — it's validated as the file compiles. Mistype it as {colros.brand.blue} and the mistake surfaces in the compiler output, not as a silently unstyled <div> in the browser. (Turn on strict: true in defineConfig if you want those treated as hard build errors from day one.)
What actually ships
Every token you defined becomes a CSS custom property on :root, named by its dashed path — colors.brand.blue → --colors-brand-blue, spacing.large → --spacing-large, fontFamily.body → --font-family-body. The compiled rule for Card just reads the ones it uses:
:root {
--colors-grey-light: #f0f0f0;
--colors-black: #0a0a0a;
--colors-brand-blue: #0070f3;
--colors-brand-green: #1f9d55;
--spacing-small: 8px;
--spacing-medium: 16px;
--spacing-large: 32px;
--font-family-heading: Inter, system-ui, sans-serif;
--font-family-body: Georgia, serif;
}
.card_hashed {
background: var(--colors-brand-blue);
color: var(--colors-grey-light);
padding: var(--spacing-large);
font-family: var(--font-family-body);
}:root carries every token you defined; the component's rule pulls only the four it references. Inspect :root in DevTools to see the whole set at once — the token system is right there in the browser as ordinary custom properties, nothing exotic.
One habit worth forming early: name a token by the role it plays, not by its raw value. spacing.pageMargin ages better than spacing.px120, because the day the margin becomes 96px you change one definition and every call site follows — whereas a token literally called px120 holding 96px is a small lie you now maintain forever. Keep the nesting shallow too; two or three levels reads cleanly at the call site, deeper starts to feel like noise.
Example 2 — The same token, a different value per breakpoint
Sometimes a token's value should change with the viewport while its name stays put — a gutter that tightens on mobile, a type step that shrinks. That's the responsive scope: one token name, values swapped by a media query, and call sites that never change.
How you define it
Tokens under responsive.base are the defaults. A key named after a media query you defined with defineMediaQuery — here '@largeMobileDown' — overrides any matching token when that query is active. You only redeclare what actually changes; everything else keeps its base value.
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
responsive: {
base: {
spacing: { gutter: "24px", pageMargin: "120px" },
},
"@largeMobileDown": {
spacing: { gutter: "16px", pageMargin: "24px" },
},
},
});How you use it
Identically to a static token. The token reference is the same string; the browser picks the right value when the breakpoint matches — no @media block at the call site.
export const Page = styled("main", {
base: {
gap: "{spacing.gutter}", // 24px → 16px below the breakpoint
paddingInline: "{spacing.pageMargin}", // 120px → 24px below the breakpoint
},
});Reach for responsive when a value should step between breakpoints. When you'd rather it scale smoothly — no hard jump at a threshold — that's a clamp(), and Salty has a helper for it: Viewport clamp turns a reference size into fluid sizing you can store in exactly these responsive tokens.
Example 3 — The same token, a different value per context (the theming bridge)
The third scope, conditional, swaps a token's value when an ancestor selector matches — a data-theme attribute or a class. Flip the attribute high in the tree and every consumer below it repaints, with no re-render and no component touched. This is the mechanism theming is built on, so this page shows only the shape of it and hands the full story to Theming.
How you define it
The structure is conditional[group][value]: { …tokens }. The group name is yours (theme is the convention); each value under it declares the same token names with different values. Referencing your static atoms keeps the palette in one place.
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}" },
},
},
});How you use it
Consume the tokens through the group's namespace — {theme.bg}, {theme.text} — then activate a set by putting the attribute on an ancestor, usually <html>:
export const Section = styled("section", {
base: { background: "{theme.bg}", color: "{theme.text}" },
});<html data-theme="dark">
...
</html>Salty compiles this to a [data-theme="dark"] { … } block that redefines those custom properties, so switching the attribute changes which block wins and the native cascade repaints — the component's own rule never knows which theme is active.
That's the whole conditional mechanism, but it's only the doorway. Theming is the deep dive into this scope — the atoms-and-molecules split (why a component should read {theme.bg} and never a raw {colors.brand.blue}), user-controlled toggles, following the OS via prefers-color-scheme, nesting themes, avoiding the first-paint flash, and when to use conditional (user- or markup-driven switches) versus responsive (environment-driven ones). Everything on that page is this scope, worked all the way out.
Mixing scopes, and one gotcha
The three scopes are not separate features fighting for the same file — they merge into one :root namespace at build time, and you can freely mix them in a single defineVariables call or split them across many files:
defineVariables({
colors: { brand: { blue: "#0070f3" } }, // static
responsive: { // responsive
base: { spacing: { gutter: "24px" } },
"@largeMobileDown": { spacing: { gutter: "16px" } },
},
conditional: { // conditional
theme: { dark: { bg: "#0a0a0a" }, light: { bg: "#f0f0f0" } },
},
});The one gotcha that catches people: a standalone defineVariables file only takes effect if it reaches the build graph. If your tokens print literally as {colors.brand.blue} in the output, the file was never imported anywhere Salty's build touches — re-export it from a styles barrel, or import it once. (Tokens passed inside defineConfig({ variables }) are picked up automatically, no import needed.)
Sharing tokens in a monorepo
Because tokens are plain TypeScript exports, they travel. In a monorepo you can keep your defineVariables calls in a local library package and import the values into each app's .css.ts files — Salty picks them up at build time exactly as it does tokens defined in-app, so every app shares one source of truth for the palette and scale.
The same caution from Dynamic Values applies, since your .css.ts files are evaluated at build: keep what you import light. A shared token module is just data, so it's cheap — but if that package drags in a heavy, browser-assuming dependency, it gets pulled into every build that imports it. Export the plain values, not a library.