→ The full options table for styled(), and what the returned component accepts.
styled
styled creates a typed component from an HTML tag or from another component. You hand it a style object; you get back a component where every variant name you declared is a typed prop — consumed for styling, and kept off the DOM unless you opt it back in.
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 how these components are meant to be composed and extended in a project, see Styled components in Basics.
Import
import { styled } from "@salty-css/astro/styled";Astro uses @salty-css/astro/styled. Next.js uses the React subpath — only the build plugin is Next-specific.
Signature
styled(tag: string | ComponentType, params: StyledParams): StyledComponenttag — an HTML tag name ("div", "button", "a", …) or a component. A component is wrapped, not replaced; see Extending a component. Third-party components have one requirement: they must accept a className prop, since that's the only channel Salty has to deliver the generated styles.
params — the options object below. Every key is optional.
Returns — a component that accepts the variant props you declared, the native props of the underlying element, and className, style, as, children, ref, plus any css-* prop tokens your styles reference.
Options
| Key | What it does |
|---|---|
base | The styles every instance gets. |
variants | Named style branches, each exposed as a typed prop. |
compoundVariants | Extra styles for when several variant values are active together. |
anyOfVariants | Extra styles shared by several variant values. Never wins a conflict. |
defaultVariants | Which branch applies when the consumer omits the prop. |
defaultProps | HTML attributes the element renders with by default. |
element | Render a different tag without redefining the styles. |
passProps | Let variant props through to the element instead of consuming them. |
className | Add your own class name alongside the generated hash. |
displayName | The name this component goes by in build output and DevTools. |
priority | Which cascade layer the rules land in. Higher wins ties. |
base
Styles applied to every instance, in Salty's full style-object syntax:
export const Card = styled("div", {
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, a function, or a promise. 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 and becomes a typed prop; each child key is one value on that axis.
export const Button = styled("button", {
base: { padding: "0.6em 1.2em", cursor: "pointer" },
variants: {
variant: {
solid: { background: "{theme.color}", color: "{theme.background}" },
outlined: { border: "1px solid currentColor" },
},
size: {
small: { fontSize: "0.8em" },
large: { fontSize: "1.2em" },
},
loading: {
true: { opacity: 0.6, pointerEvents: "none" },
},
},
});<Button variant="solid" size="large" loading />Declare only the values you want to style — a boolean variant needs true or false, not both.
Variant props are consumed by Salty and do not reach the DOM unless passProps says otherwise. That default keeps loading from landing on the element as an unknown attribute, but it has one consequence worth internalizing: a variant named after a real HTML attribute — disabled, hidden, open, checked — will style the element without setting the attribute. The button looks disabled and stays clickable. Either keep the native attribute and style it with &:disabled in base, or forward it explicitly with passProps: ["disabled"]. The full version of that decision is in Interactive state.
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: [
{ variant: "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 } },
],The OR applies within an entry as well as across them, so the three above can also be written as one entry listing several conditions — the block applies when any of them is active. That's the whole difference from compoundVariants, which requires every listed condition to match.
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, and it's the part worth understanding before you use it: an anyOfVariants rule is a shared baseline, not an override. Wrapping it in :where() removes the class of bug where a broad "all loud tones are bold" rule quietly beats the specific rule you wrote for one tone. The cost is that it can never win a conflict — if the shared rule must apply, it belongs in compoundVariants or base instead.
defaultVariants
Variant values applied at render when the consumer omits the prop.
defaultVariants: { variant: "outlined", size: "small" },<Button /> renders the outlined/small branch; <Button variant="solid" /> overrides only that axis. Note that className does not auto-apply defaultVariants — that behavior is specific to styled, because only styled sees the render.
defaultProps
Default DOM props, passed straight through to the rendered element.
defaultProps: { type: "button" },The distinction from defaultVariants is what the value is used for: a defaultVariants value is a lookup key into your variants object and never reaches the element; a defaultProps value is an attribute on the element and never affects styling.
element
Renders a different HTML tag while keeping the styling and variants defined against the original tag argument.
export const Heading = styled("div", {
element: "h2",
base: { fontSize: "1.5rem", fontWeight: 700 },
});Per instance, the consumer can override it with the as prop, which takes either a tag name or a component: <Heading as="h3">, <Heading as={Link}>. Note the difference from passing a component as tag — element swaps the tag on the same component, while styled(Heading, …) creates a new component layered on top of the old one.
passProps
Opts variant props back into the set forwarded to the underlying element or component.
| Value | Behavior |
|---|---|
false (default) | Variant props stay with Salty; only native HTML attributes are forwarded. |
true | All variant props are forwarded. |
"href" | Only that prop is forwarded. |
["href", "target"] | Those props are forwarded. |
The common case is wrapping a component that needs specific props to function — next/link's href, a router link's to, an input's value:
import NextLink from "next/link";
export const Link = styled(NextLink, {
passProps: ["href", "prefetch"],
base: { color: "{colors.brand.main}" },
});Without this, NextLink never receives href: Salty treats every non-native prop as its own, and href isn't a native prop of a component it doesn't recognize.
className
Appends your own class names alongside the generated hash — one as a string, several as an array.
styled("div", { 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. Useful as a stable selector for external CSS, tests, and DevTools scanning.
displayName
Overrides the name derived from the export.
export const Card = styled("div", { displayName: "Card", base: { padding: "1rem" } });It's used in build output and in the data-component-name attribute rendered in dev builds; that attribute is stripped in production. Worth setting when a wrapped component would otherwise show up under an opaque name.
priority
The cascade layer the component'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…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 extra class chaining, no specificity inflation — it only decides which @layer block the rule is written into.
export const Button = styled("button", { base: { color: "red" } }); // l0
export const DangerButton = styled(Button, { base: { color: "crimson" } }); // l1, beats Button
export const LoudDanger = styled(DangerButton, { base: { color: "magenta" } });// l2, beats both
export const Forced = styled("button", { priority: 2, base: { color: "hotpink" } }); // l2 directlyTwo behaviors that aren't obvious from the type:
- Wrapping auto-bumps, and it accumulates. A plain
styled("button", …)starts at0. Every level of extension on top of it takes the next layer —1,2,3, and so on down the chain — so an extension always beats what it extends, however deep the chain runs. !importantinverts layer order. If two competing rules are both!important, the earlier layer wins — that's native CSS behavior inside cascade layers, not a Salty quirk. Raisingpriorityor wrapping the component is almost always the fix you actually wanted. Scoping and composition covers the whole precedence model.
The rendered component
The component returned by styled accepts:
- Variant props — typed from
variants, consumed by Salty, not forwarded unlesspassPropssays so. - Native props of the underlying element —
buttongetstypeanddisabled,agetshrefandtarget, and so on. className— appended to the generated class rather than replacing it.style— a normal inline declaration, which outranks every layered rule Salty generates.as— per-instance override of what gets rendered, the call-site equivalent ofelement. Takes a tag name or a component.css-*— typed values for any prop tokens your styles reference.childrenandref— refs are forwarded to the underlying element. When the component wraps something else, whether the ref reaches a DOM node is that component's business, not Salty's: a wrapped component that doesn't forward its own ref won't start doing so here.
In dev builds the element also carries data-component-name; it's stripped in production.
Prop tokens (css-*)
Reference {props.X} anywhere in base or variants and the compiler generates a typed css-X prop on the component.
export const Box = styled("div", {
base: { color: "{props.color}", backgroundColor: "{props.bgColor}" },
});<Box css-color="white" css-bg-color="tomato" />The naming chain runs camelCase → dash-case → CSS variable: the token {props.bgColor} produces the JSX prop css-bg-color, and Salty writes it into the element's inline style as --props-bg-color — which the compiled rule already reads through var(--props-bg-color).
This is the one mechanism here that stays live in the browser rather than being resolved at build time, which is the point of it: the value is genuinely the consumer's to pick. Two rules follow from the implementation. An unset prop writes nothing at all, so pair the token with a fallback when you need a default. And css-* props are stripped before forwarding, so they never leak onto the DOM as stray attributes.
Reach for prop tokens when the component should own a typed, discoverable contract; use a plain CSS custom property and style=undefined when the variable name itself is the shared contract between several components. Dynamic values covers the tradeoff.
Extending a component
Passing a component as the first argument wraps it.
import { Button } from "./button.css";
export const PrimaryButton = styled(Button, {
base: { background: "{colors.brand.main}", color: "white" },
});What happens on the wrap:
- The wrapped component's class is preserved; both classes end up on the element.
- The new
basewins, via the automatic priority bump rather than a more specific selector. - Variants from both layers coexist. On a name collision, the outer layer wins.
- Chains compose —
styled(PrimaryButton, …)is fine, and each link takes the next layer.
Third-party components work on the same terms as long as they accept className. Anything they need in order to function has to be forwarded with passProps.
Compiler requirements
Four constraints decide whether a styled 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. Astyledcall 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, so a component nothing renders produces no CSS.
- 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.
Generated class names are content-hashed and de-duplicated: two components with identical styles share a class.