→ The full options table for className(), and what the returned class string carries.
className
className takes the same style object styled takes and hands back a class string instead of a component. Variants, nested selectors, tokens, media queries, templates — the whole style surface, compiled into the same static stylesheet. What you don't get is the wrapper: nothing renders, and putting the class on an element is your job.
The call has to live in a file the compiler reads (.css.ts, .css.tsx, .salty.ts, .styled.ts, .styles.ts) and has to be a top-level export. In a plain .ts file it type-checks fine and emits no CSS.
For where this API earns its keep in a project, with worked examples, see Class names in Basics.
Import
import { className } from "@salty-css/astro/class-name";Astro uses @salty-css/astro/class-name. Next.js uses the React subpath — only the build plugin is Next-specific. Outside the frameworks Salty publishes a styled for, import from core, where className is the authoring API:
import { className } from "@salty-css/core/class-name";Signature
className(params: StyledParams): ClassNameFunctionparams — the options object below. It's the same type styled takes, so a few keys are accepted and then do nothing here. Every key is optional.
Returns — a string-coercible object carrying the generated class, plus a .variant() method for opting into variants:
type ClassNameFunction = string & {
variant: (name: string, value: string) => ClassNameFunction;
generator: ClassNameGenerator;
isClassName: true;
};Options
| Key | What it does |
|---|---|
base | The styles every use of the class gets. |
variants | Named style branches, each opted into at the call site. |
compoundVariants | Extra styles for when several variant values are active together. |
anyOfVariants | Extra styles shared by several variant values. Never wins a conflict. |
defaultVariants | Accepted, but nothing applies it for you. |
className | Add your own class name alongside the generated hash. |
displayName | The name this class goes by in build output. |
priority | Which cascade layer the rules land in. Higher wins ties. |
base
Styles applied everywhere the class is used, in Salty's full style-object syntax:
export const card = className({
base: {
padding: "1.5rem",
borderRadius: "12px",
background: "{theme.background}",
"&:hover": { boxShadow: "0 8px 24px rgb(0 0 0 / 0.08)" },
"& > svg": { flexShrink: 0 },
"@tabletDown": { padding: "1rem" },
},
});What's accepted inside the object:
- Nested selectors via
&—"&:hover","&:not(:hover)","& > svg","& + &", grouped"&:hover, &:focus". - Token references —
{path.to.token}, themed ones as{theme.xxx}. Paths are validated at build time. - At-rules — an inline
"@media (...)"/"@container (...)"key, or a name you registered withdefineMediaQueryas"@tabletDown". An unknown@nameis emitted as a literal at-rule and silently never matches. - Templates and modifiers — applied as keys and values inside the object.
- Values —
string,number, an array, a function, or a promise. Arrays are joined with,, which is what you want forboxShadowand friends. Functions and promises are resolved by the compiler, so the result lands in the stylesheet as a literal. Bare numbers get the unit fromdefineConfig({ defaultUnit }),pxby default.
variants
Each top-level key is an axis, each child key is one value on it. The difference from styled is on the consuming side: there are no props here, so nothing is applied until you ask for it by name.
export const buttonClass = className({
base: { padding: "0.6em 1.2em", cursor: "pointer" },
variants: {
tone: {
solid: { background: "{theme.color}", color: "{theme.background}" },
ghost: { background: "transparent", border: "1px solid currentColor" },
},
size: {
small: { fontSize: "0.8em" },
large: { fontSize: "1.2em" },
},
loading: {
true: { opacity: 0.6, pointerEvents: "none" },
},
},
});buttonClass.variant("tone", "solid").variant("size", "large");Each call appends "<name>-<value>" to the class string and returns a new instance, so calls chain and the original is never mutated. Declare only the values you want to style — a boolean variant needs true or false, not both — and note that booleans are activated with the value as a string: .variant("loading", "true").
.variant() takes plain strings, which means a misspelled axis or value isn't a type error. It appends a class, no rule matches it, and nothing visibly happens. When an axis has more than a couple of values, the helper pattern below is the cheapest way to get the names type-checked once, in one place.
compoundVariants
An array of entries that each list variant values plus a css block. The block applies only when all listed values are active.
compoundVariants: [
{ tone: "solid", size: "large", css: { fontWeight: 700 } },
],anyOfVariants
Same shape, OR semantics: the block applies when any of the listed entries matches. It's for "treat these N branches the same" rules that would otherwise be a wall of near-identical compound entries.
anyOfVariants: [
{ tone: "success", css: { fontWeight: 700 } },
{ tone: "warning", css: { fontWeight: 700 } },
{ tone: "danger", css: { fontWeight: 700 } },
],These rules are emitted inside :where(), which means zero specificity. Any regular variants or compoundVariants rule touching the same property wins against them, regardless of source order or layer. That's deliberate: an anyOfVariants rule is a shared baseline, not an override. If the shared rule must apply, it belongs in compoundVariants or base instead.
defaultVariants
Accepted by the type, and nothing applies it. defaultVariants works on styled because styled sees the render and can fill in the props you left out; className hands you a string at build time and never gets a second look at it. .variant() is the only thing that appends a variant class, so a bare buttonClass is the base class and nothing else.
Defaults are still worth having — you just express them in a function instead:
export const button = ({
tone = "solid",
size = "small",
}: {
tone?: "solid" | "ghost";
size?: "small" | "large";
} = {}) => buttonClass.variant("tone", tone).variant("size", size);button(); // base + tone-solid + size-small
button({ tone: "ghost" }); // base + tone-ghost + size-smallThat wrapper is also where the axis names stop being loose strings, which is the second reason to write it.
className
Appends your own class names alongside the generated hash — one as a string, several as an array.
export const card = className({ className: "card", base: { padding: "1rem" } });The element renders with both classes, so a .card { … } rule from a legacy stylesheet or a third-party framework will match it. It's the stable half of the output: the hash changes whenever the styles change, your class doesn't, which makes it the thing to point tests, analytics selectors and external CSS at. The option shares its name with the function, so className({ className: "card" }) is correct as written.
displayName
Overrides the name derived from the export.
export const card = className({ displayName: "Card", base: { padding: "1rem" } });It's used in build output — most visibly in the generated filename, which is how you get from a hash in DevTools back to the definition that produced it. Unlike styled, there's no element here to carry a data-component-name attribute, so this only ever shows up on the build side.
priority
The cascade layer the class's rules land in. 0–8, mapping to l0–l8; eight is the ceiling.
Salty declares the full layer order once, at the top of the generated stylesheet:
@layer imports, reset, global, templates, fonts, l0, l1, l2, l3, l4, l5, l6, l7, l8;A rule in a later layer wins against one in an earlier layer at equal specificity, regardless of source order or which file compiled first. priority does not change the selector — no class chaining, no specificity inflation — it only decides which @layer block the rule is written into.
Two things follow from that, and both matter more here than they do on styled:
- A class always starts at
0.styledauto-bumps when it wraps another Salty component, one layer per level of extension. There's no wrapping in this API, so nothing bumps for you —priorityis the only way a class moves up. - Same layer, same specificity, and the winner is source order in the generated bundle. That comes up the moment you put a Salty class on a Salty component, since both land in
l0by default:<Card className={highlight} />, and which one wins is decided by the order the compiler happened to concatenate them in. Don't rely on it. Give the class the priority that says what you meant —priority: 1for a class intended to override components it's placed on.
!important inverts all of this — between two !important rules the earlier layer wins, which is native CSS behavior inside cascade layers rather than a Salty quirk. Raising priority is almost always the fix you actually wanted. Scoping and composition covers the whole precedence model.
Options with no effect
StyledParams also defines element, passProps and defaultProps. They're accepted by the type and do nothing here — they all act on a rendered element, and this API doesn't render one. The as prop is the same story from the call site: there's no component to pass it to.
The returned value
| Member | What it gives you |
|---|---|
| string coercion | The full class string — generated hash, your className entries, and any variant classes chained on so far. |
.variant(name, value) | A new ClassNameFunction with "name-value" appended. Immutable, and chainable: .variant("tone", "solid").variant("size", "large"). |
.generator | The underlying ClassNameGenerator — the object that produced the CSS at build time. Exposed for tooling that wants to read the resolved variant axes or recompute the hash; not for app code. |
.isClassName | Always true. Useful when you're writing a helper that accepts either a raw string or a Salty class and needs to tell them apart. |
The string reads left to right in the order the pieces were added:
buttonClass;
// → "eyjPN btn"
buttonClass.variant("tone", "solid").variant("size", "large");
// → "eyjPN btn tone-solid size-large"One implementation detail is worth knowing before it surprises you: the return value is a String object, not a string primitive — that's how it carries .variant() around with it. Almost everywhere this is invisible. It concatenates, interpolates, compares and lands in a class attribute exactly like text. But typeof reports "object", so a strict typeof x === "string" check somewhere in your stack will reject it. Coerce it first with String(...) or a template literal:
<div class={`${card}`} />What gets generated
Variants are appended to the same class rather than getting their own, so one definition produces one selector plus qualified forms of it:
| Feature | Selector |
|---|---|
base | .eyjPN |
variants | .eyjPN.tone-solid |
compoundVariants | .eyjPN.tone-solid.size-large |
anyOfVariants | .eyjPN:where(.tone-success, .tone-warning) |
The class is five alphabetic characters, hashed from the style object only — not the export name, not the file path. Edit a padding and the hash changes; rename the export and it doesn't. Two definitions with byte-identical styles get the same class, which is deduplication working as designed.
The CSS lands in saltygen/css/cl_<export-name>-<hash>.css, the same namespace styled components use — the compiler doesn't distinguish between them at that level, because by then there's no component in the picture either way. File structure is the full map of what's in saltygen/.
Because the value stringifies to that class, it can be interpolated straight into another definition's selector key — targeting by identity rather than by tag:
import { icon } from "./icon.css";
export const Button = styled("button", {
base: {
padding: "0.6em 1.2em",
[`& ${icon}`]: { opacity: 0.8 },
},
});What className doesn't do
Everything missing is component ergonomics rather than CSS:
- No extending. There's no
className(otherClass, …). You compose by putting two classes on the element and lettingprioritysettle any conflict. - No element,
as,passPropsordefaultProps. Covered above — nothing to render, nothing to forward. - No automatic
defaultVariants. Chain.variant(), or wrap the class in the helper above. - No
css-*prop tokens. Those are typed JSX props, so they need a component. When you want a value the consumer sets, declare a custom property with a fallback inbase—padding: "var(--panel-padding, 1rem)"— and set it from astyleattribute or a parent rule. Dynamic values covers both sides of that tradeoff.
If you want any of that, styled is this same styling surface with a component contract attached.
Compiler requirements
Four constraints decide whether a className call produces any CSS at all:
- The filename suffix —
.css.ts,.css.tsx,.salty.ts,.styled.ts, or.styles.ts. Use.css.tsxwhen the file needs JSX. - A top-level
export— the compiler collects exported calls. AclassNamecall created inside a function or left unexported emits nothing. A private helper is fine as long as what it returns is exported. - Something has to import it — unused exports are tree-shaken. This one bites harder here than it does with
styled, because a class string is so easy to use without importing anything: copying a hash out of DevTools into a hand-written HTML file is not a reference the bundler can see, and the hash changes when the styles do anyway. - The file runs in Node at build time — importing heavy runtime libraries, or anything touching
window, slows or breaks compilation. Derive those values elsewhere and import the result.