→ Get the same styling surface back as a class string, for the markup you do not own.
Class Names
className() takes the same style object styled() takes and hands back a class string instead of a component. Variants, nesting, pseudo-classes, tokens, media queries, templates — the whole styling surface, compiled into the same static stylesheet while your project builds. What you don't get is the wrapper: nothing is rendered for you, and putting the class on an element is your job.
That's the right trade in two situations. The first is inside an app you're already building with styled, when a piece of markup isn't yours — a third-party component that wants class names for its internal parts, or something complex enough that handing it a string is less ceremony than wrapping it. The second is when there's no component in the picture at all: a script that renders its own DOM, an element that only exists after some widget initializes, or a framework Salty doesn't publish a styled for.
The file rules are the same as everywhere else in Salty. The definition lives in a file ending in .css.ts (or one of the other recognized suffixes), it has to be exported, and the compiler reads it at build time. A plain .ts file with identical contents compiles to nothing.
Deconstructing className parameters
Every className(...) is one call with one options object. Here's a button with base styles, two variant axes, and a stable class name alongside the generated hash:
Using it is a matter of reading the export:
import { buttonClass } from "./styles/button.css";
buttonClass;
// → "eyjPN btn"
buttonClass.variant("tone", "solid").variant("size", "large");
// → "eyjPN btn tone-solid size-large".variant() returns a new instance with "<n>-<value>" appended, so it chains and the original is never mutated. Boolean variants work the same way, with the value passed as a string: buttonClass.variant("warning", "true").
Two things about that value are worth knowing before they surprise you.
It's a String object, not a string primitive — that's how it carries .variant() around with it. Almost everywhere this is invisible: it concatenates, interpolates, and compares as text like anything else. But if something in your stack does a strict typeof x === "string" check, coerce it first with String(...) or a template literal.
defaultVariants is accepted, but className doesn't apply it for you. .variant() is the only thing that appends classes, so a bare call gets you the base class and nothing else. When you want defaults, wrap the class in a small function:
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-smallThe remaining options — compoundVariants, anyOfVariants, priority, displayName — behave exactly as they do on styled and are listed on the className reference page. element, as, passProps and defaultProps are accepted by the type but do nothing here; there's no element for them to act on.
Example 1 — Handing a class to markup you don't own
You can wrap a third-party component with styled as long as it accepts a className prop, and often that's the nicer answer. It stops being the nicer answer when the component has several class-name slots, because a wrapper only ever reaches the outer one. react-select is the everyday version of this: one prop, one callback per inner part, each expected to return a class string.
How you define it
Each slot is its own small definition. No wrapper, no props, no forwarding:
import { className } from "@salty-css/react/class-name";
export const selectControl = className({
base: {
display: "flex",
alignItems: "center",
minHeight: "2.75rem",
padding: "0 0.75rem",
borderRadius: "8px",
border: "1px solid {colors.border}",
background: "{theme.bg}",
},
variants: {
state: {
focused: { borderColor: "{colors.brand.main}" },
},
},
});
export const selectMenu = className({
base: {
marginTop: "0.25rem",
borderRadius: "8px",
overflow: "hidden",
background: "{theme.bg}",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
},
});
export const selectOption = className({
base: {
padding: "0.5rem 0.75rem",
cursor: "pointer",
"&:hover": { background: "{colors.brand.subtle}" },
},
variants: {
state: {
selected: { background: "{colors.brand.main}", color: "{colors.paper}" },
},
},
});How you use it
import Select from "react-select";
import { selectControl, selectMenu, selectOption } from "./styles/select.css";
export const FlavourPicker = ({ options }) => (
<Select
options={options}
// `unstyled` drops react-select's own presentational CSS, so yours is the
// only thing in play.
unstyled
classNames=\{{
control: (state) =>
`${state.isFocused ? selectControl.variant("state", "focused") : selectControl}`,
menu: () => `${selectMenu}`,
option: (state) =>
`${state.isSelected ? selectOption.variant("state", "selected") : selectOption}`,
}}
/>
);Worth noticing what the callbacks are doing: react-select tracks focus and selection itself and hands that state to you, so your variants get driven by the library's state rather than by state you'd otherwise have to duplicate. The slot names and the state object are its API; the strings going into them are the only part Salty is responsible for.
Because it's a string, it composes with everything that already composes strings — clsx, a template literal, a conditional, a class the library insists on adding itself:
import clsx from "clsx";
<button className={clsx(buttonClass.variant("tone", "solid"), isWide && "full-width")} />The styling side is unchanged by any of this: same hashing, same cascade layers, same tokens, same output file as a styled component would produce. The only thing you gave up is the prop-to-variant mapping, and in this example there was no prop to map — the library owns those.
Example 2 — No components in sight
The other half of the job is DOM you didn't render: markup a third-party script injects, an element that only exists after some widget finishes initializing, a page where you're writing plain TypeScript against the document. Get the element, attach the class, done — you've been doing this since jQuery, just with fewer dollar signs.
How you define it
Define the states you'll actually switch between as variants, and leave everything the browser already tracks in base:
import { className } from "@salty-css/react/class-name";
export const panelClass = className({
base: {
padding: "1rem",
borderRadius: "8px",
background: "{theme.bg}",
transition: "opacity 150ms ease",
// Hover and focus are the browser's job — no JavaScript involved.
"&:hover": { boxShadow: "0 8px 24px rgba(0, 0, 0, 0.08)" },
"&:focus-within": { outline: "2px solid {colors.brand.main}" },
},
variants: {
state: {
open: { opacity: 1, pointerEvents: "auto" },
closed: { opacity: 0, pointerEvents: "none" },
},
},
});That split is worth holding onto beyond this example: only the state your code owns needs a variant. Hover, focus, disabled, a checked box, an invalid field — the browser tracks all of it and will style it for free from base, whether the element came from your app or from someone else's script. The Interactive State page takes that idea further.
How you use it
import { panelClass } from "../styles/panel.css";
const setPanelState = (el: HTMLElement, state: "open" | "closed") => {
el.className = `${panelClass.variant("state", state)}`;
};
widget.on("ready", () => {
const panel = document.querySelector<HTMLElement>("[data-widget-panel]");
if (panel) setPanelState(panel, "closed");
});
toggle.addEventListener("click", () => {
setPanelState(panel, panel.dataset.open === "true" ? "closed" : "open");
});Two practical notes, both of which cost about a minute to learn the hard way.
Assigning el.className replaces the element's whole class list, which is what you want when Salty owns that element and not what you want when a script has put its own classes there. To add instead of replace, split first — classList.add() takes one class per argument and throws on a string containing spaces:
el.classList.add(...`${panelClass}`.split(" "));And the class has to be imported by code your bundler actually reaches. That's how Salty knows the class is in use; a definition nothing imports gets tree-shaken and emits no CSS. Copying a hash out of DevTools into a hand-written HTML file doesn't count as a reference — and the hash changes when the styles do, which is the point of it.
Frameworks without a styled
styled is the one API that has to know your framework, because it hands back a component and there's no single component shape that covers React, Astro, Vue, Svelte and Angular at once. Everything else Salty exports produces text — a custom property, a keyframes rule, a class name — and text doesn't care what put it on the page.
So on a framework Salty doesn't publish a styled for, className is the authoring API, and nothing else about the library changes. Tokens, themes, templates, media queries, fonts and helpers all work exactly as documented. Import from core when React isn't in the picture:
import { className } from "@salty-css/core/class-name";From there it's a class binding like any other:
<!-- Vue -->
<button :class="buttonClass">Save</button>
<!-- Svelte -->
<button class={buttonClass}>Save</button>The defaults wrapper from the first section is also the natural seam for a component in your own framework — it already has the prop names, the defaults and the types, and what it renders is entirely up to you. The actual compatibility question isn't which framework you're on but whether your build runs on Vite or Webpack; the Framework agnostic guide covers the wiring.
What className doesn't do
Everything missing is component ergonomics rather than CSS, and it's all in one place:
- No extending. There's no
className(otherClass, …)— you compose by putting two classes on the element and lettingprioritysettle any conflict. - No element,
as,passPropsordefaultProps. There's no element to render or props to forward. - No automatic
defaultVariants. Chain.variant()or use the wrapper pattern above. - No
css-*prop tokens. Those are typed JSX props, so they need a component. For 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.
If you want any of that, styled is the same styling surface with the component contract attached.