→ Named branches of a style, picked at the call site and compiled before your app runs.
Variants
A variant is a named branch of a style, picked at the call site. size="large", tone="danger", a loading flag — one axis, a few values on it, every branch compiled to plain CSS before your app ever runs.
This page exists because variants aren't really a feature of styled. They're a shape that turns up in three places — styled, className, and defineTemplates — with the same five keys and the same semantics each time. Only the way you switch one on changes. Learn the shape once and the third one is free.
And to get the obvious out of the way: not that kind of variant — no sacred timeline, no TVA. Just a class name that's either on the element or isn't, and the only thing monitoring it is the cascade.
Why a variant, and not something else
Three things could all express "this button is large," and it's worth being precise about why this one wins.
Not a second component. Button, LargeButton, PrimaryButton, LargePrimaryButton — two axes with three values each is nine components, and every one of them is a place for the padding to drift. Variants keep it as one component with two knobs. The Styled API page calls this thinking in axes rather than in branches, and it's the whole reason the API is shaped this way: base is what's true no matter what, variants are the named dimensions stacked on top, and the states your component can be in are the combinations.
Not a style object computed at render. You could branch in JavaScript and hand the result to a style prop. That works, and it moves styling into the render path — a new inline declaration per element, no cascade, no cache, nothing shared between instances. A variant is decided before your app runs. What ships is a stylesheet; the prop only chooses which class is on the element.
Not a loose class name. A string has no contract. A variant name is a typed prop, so your editor lists the axes, autocompletes the values, and a typo is a compile error rather than a component that silently renders unstyled.
The cost, plainly: the compiler can't know which props you'll pass, so every value you declare is compiled into the stylesheet whether a call site ever uses it or not. A ten-value axis you use two of is eight rules of dead weight in saltygen/index.css. In practice that's cheap — the CSS is static, hashed, deduplicated, and cached — but it's a real reason to keep axes small and deliberate rather than enumerating every value a designer might one day want.
The shape, once
Five keys, and they mean the same thing on all three APIs.
| Key | What it does |
|---|---|
variants | The axes. Each name is one dimension; each value under it is one branch. |
defaultVariants | Which branch applies when nobody picks. |
compoundVariants | Extra styles when all the listed values are active — AND. |
anyOfVariants | Extra styles when any of them is — OR. Zero specificity by design. |
A true / false value | The boolean form. Declare only the side you want to style. |
What changes between the three is how a branch gets switched on:
| API | You activate a variant by… | Defaults applied for you? |
|---|---|---|
styled | passing a prop — <Button size="large" /> | yes |
className | chaining — buttonClass.variant("size", "large") | no |
defineTemplates | naming it at the call site — textStyle: "headline.large@bold" | yes |
Whatever the route, the result on the element is the same: the hashed class the component or class already had, plus one class per active axis. Nothing computes a style; something picks a class.
Example 1 — Variants as typed props on styled
The everyday case, and the one the other two are variations of.
How you define it
Two axes and a boolean flag. Note that nothing here is conditional logic — it's a description of the branches, and the compiler emits all of them:
How you use it
tone, size, and loading are now typed props. Omit one and defaultVariants fills it in; a boolean variant is a bare flag, the way HTML booleans read:
---
import { Button } from "../components/button.css";
---
<!-- solid + small, from the defaults -->
<Button>Save</Button>
<Button tone="ghost" size="large">Cancel</Button>
<Button loading>Saving…</Button>Two behaviours worth holding onto. Variant props are consumed by Salty and don't reach the DOM — the rendered <button> gets no stray tone="ghost" attribute — which is what keeps a loading flag from becoming an invalid HTML attribute. And every native prop the underlying element accepts (onClick, disabled, aria-*, ref, className) still works alongside the variant props, because those pass straight through.
Example 2 — The same axes, no component (className)
When there's no component to build — markup you don't own, a third-party widget with a class slot — className takes the identical options object. The definition is a copy-paste of the one above; only the activation differs.
How you define it
import { className } from "@salty-css/astro/class-name";
export const buttonClass = className({
className: "btn",
base: { borderRadius: "6px", cursor: "pointer" },
variants: {
tone: {
solid: { background: "{colors.brand.main}", color: "{colors.paper}" },
ghost: { background: "transparent", borderColor: "currentColor" },
},
size: {
small: { fontSize: "0.85rem" },
large: { fontSize: "1.15rem" },
},
},
});How you use it
There's no prop to read, so you chain. .variant() returns a new value with "<n>-<value>" appended — the original is never mutated, so chains are safe to build up conditionally:
buttonClass;
// → "eyjPN btn"
buttonClass.variant("tone", "ghost").variant("size", "large");
// → "eyjPN btn tone-ghost size-large"That output is the clearest look you'll get at what a variant actually is: a class, appended. The styled version does the same thing — it just reads the prop and does the appending for you.
One gap to know before it bites: className accepts defaultVariants but doesn't apply them. .variant() is the only thing that appends a class, so a bare buttonClass gives you the base and nothing else. Wrap it in a small function when you want defaults — there's a worked version on the Class Names page, along with the boolean form (the value goes in as the string "true").
Example 3 — Variants inside a template
A template is a bundle of styles applied by name from inside a style object, and a template node can carry the same variant machinery. This is the version that pays off across a whole design system rather than one component: define the axis once, and every text style in the project inherits it.
How you define it
A node becomes variant-capable the moment it has a base or variants key:
import { defineTemplates } from "@salty-css/core/factories";
export default defineTemplates({
textStyle: {
headline: {
base: {
fontFamily: "{fonts.headline}",
fontWeight: "300",
lineHeight: "1.2em",
},
variants: {
bold: {
true: { fontWeight: "600", letterSpacing: "0.02em" },
},
},
small: { fontSize: "{fontSize.headline.small}" },
large: { fontSize: "{fontSize.headline.large}" },
},
},
});How you use it
The call site is a string with the variants after an @, or an object if you find that clearer — both do the same thing:
import { styled } from "@salty-css/astro/styled";
// no variant → base only
export const Title = styled("h1", { base: { textStyle: "headline.large" } });
// string form; a boolean variant is a bare flag here too
export const Hero = styled("h1", { base: { textStyle: "headline.large@bold" } });
// object form
export const Hero2 = styled("h1", { base: { textStyle: { name: "headline.large", bold: true } } });Leaves inherit their parent's base and variants, so headline.large already carries the family and the bold option — the leaf only adds its size. One value per axis per call, and re-declaring an axis value on a leaf replaces that bundle rather than merging into it. Templates has the full treatment.
Combining axes: AND, OR, and who wins
Two keys handle the rules that don't belong to a single axis.
compoundVariants is AND. It applies only when every listed value is active — the place for the small correction a specific combination needs, without polluting either axis:
compoundVariants: [
{ tone: "solid", size: "large", css: { fontWeight: 700, letterSpacing: "0.01em" } },
],anyOfVariants is OR. It applies when any one of the listed values is active — the place for a rule several branches share, written once instead of pasted into each:
anyOfVariants: [
{ tone: "solid", css: { textTransform: "uppercase" } },
{ tone: "danger", css: { textTransform: "uppercase" } },
],Here's the part to read before you rely on it: anyOfVariants rules are emitted inside :where(), which gives them zero specificity. Any regular variants or compoundVariants rule touching the same property beats them — regardless of source order, regardless of layer. That's deliberate, and it makes them a good fit for shared baselines that individual branches should be free to override. It also makes them the wrong tool when the shared rule has to win. If it must win, it belongs in compoundVariants or in base.
Between the axes themselves there's no precedence to learn: two axes touching the same property resolve by the ordinary cascade, so keep axes orthogonal — one axis owns colour, another owns size — and the question stops coming up.
How far to take it
Variants stretch further than people expect and then stop fairly abruptly. The edges, roughly in the order you'll meet them:
Closed sets only. A variant is a fixed list of names known at build time. Three sizes, four tones, an on/off flag — fine. A colour a user picks in a settings panel, a width from a CMS, a duration you can't know in advance — not a variant, and no amount of declaring values will make it one. That's what typed css-* prop tokens are for, and beyond them runtime styles. The tell is simple: if you can't write the list down, it isn't an axis.
Don't lift state the browser already has. Hover, focus, disabled, open, invalid — the browser tracks all of it and Salty lets you nest the selector directly. A variant that mirrors a state the platform already owns is duplicated truth, and in the case of disabled it's actively a bug: because variant props don't reach the DOM, a disabled variant styles a button that stays entirely clickable. Interactive State walks the whole ladder, including that trap and the passProps escape hatch when you genuinely do want a variant name that's also a real HTML attribute.
Axes multiply, and that's fine — until the pairings stop making sense. Three axes of four values is a component that can render sixty-four ways. That's sixty-four opportunities to handle a state, not sixty-four obligations: you style the combinations that mean something and let the rest fall back to base. Where it does turn into a problem is when two axes only make sense in specific pairings — that's usually a sign there are two components in there rather than one, and giving each its own axes is the fix. Wrapping with styled(Button, …) keeps the shared base and inherits the original's variants, so splitting costs less than it sounds.
One name, two meanings. defineFont({ variants: [...] }) has nothing to do with any of this — there, a variant is one @font-face rule (a weight, a style, a file). Same word, unrelated machinery. Worth knowing so the array shape doesn't read as a mistake when you meet it on the Fonts page.
They're styling, not state management. A variant describes what a mode looks like. What puts your component into that mode — a hook, a form library, a state machine — stays yours, and Salty has no opinion about it beyond wanting the value handed over as a prop.
Coming soon — the recipes below put variants to work in real projects.