RSC-Safe Variants for the App Router — Prop-Driven Style Branching Without 'use client' Boundaries
Variants in the App Router
Defining variant axes
Variants are defined within the variants object of a styled component. Each top-level key is a named axis (size, tone, variant, etc.) and each child key is one value on that axis. Every axis becomes a typed prop on the component.
// /components/button/button.css.ts import { styled } from "@salty-css/react/styled"; export const Button = styled("button", { base: { display: "block", padding: "0.6em 1.2em", border: "1px solid currentColor", background: "transparent", color: "currentColor", cursor: "pointer", transition: "200ms", }, variants: { // Define a "variant" property with different values variant: { outlined: { // Default styles }, solid: { "&:not(:hover)": { background: "black", borderColor: "black", color: "white", }, "&:hover": { background: "transparent", borderColor: "currentColor", color: "currentColor", }, }, }, // Define a "size" property with different values size: { small: { fontSize: "0.8em", padding: "0.4em 0.8em", }, medium: { fontSize: "1em", padding: "0.6em 1.2em", }, large: { fontSize: "1.2em", padding: "0.8em 1.6em", }, }, }, });
Rendering with variants
import { Button } from "./button/button.css"; export const MyComponent = () => { return ( <div> <Button>Default Button</Button> <Button variant="solid">Solid Button</Button> <Button variant="outlined" size="large"> Large Outlined Button </Button> </div> ); };
Compound Variants
Compound variants let you apply styles when multiple variant conditions are met simultaneously:
import { styled } from "@salty-css/react/styled"; export const Button = styled("button", { base: { // ... base styles }, variants: { variant: { outlined: { /* ... */ }, solid: { /* ... */ }, }, size: { small: { /* ... */ }, large: { /* ... */ }, }, }, compoundVariants: [ { // Apply these styles when both conditions are true variant: "solid", size: "large", css: { fontWeight: "bold", textTransform: "uppercase", }, }, ], });
Default Variants
You can set default values for your variants:
import { styled } from "@salty-css/react/styled"; export const Button = styled("button", { base: { // ... base styles }, variants: { variant: { outlined: { /* ... */ }, solid: { /* ... */ }, }, size: { small: { /* ... */ }, medium: { /* ... */ }, large: { /* ... */ }, }, }, defaultVariants: { variant: "outlined", size: "medium", }, });
With default variants, you don't need to specify these props every time, as they'll be applied automatically.
Boolean variants
For toggle-style props, declare a variant whose values are true / false:
export const Button = styled("button", { base: { padding: "0.5rem 1rem" }, variants: { loading: { true: { opacity: 0.6, pointerEvents: "none" }, }, }, });
<Button loading>Submitting…</Button>
The variant only needs entries for the values you want to style — there's no requirement to declare both true and false.
anyOf variants — OR logic
compoundVariants requires all listed values to be active. anyOfVariants flips that to "any of these" — useful when several variants should share a small rule without duplicating the CSS.
One thing to know upfront: anyOfVariants rules are emitted with :where() and carry zero specificity. A regular variants rule on the same property always wins — by design. If you need the shared rule to win, move it into compoundVariants or base instead.
export const Badge = styled("span", { base: { display: "inline-block", padding: "2px 8px", borderRadius: "999px", }, variants: { tone: { success: { background: "#16a34a", color: "white" }, warning: { background: "#eab308", color: "black" }, danger: { background: "#dc2626", color: "white" }, neutral: { background: "#e5e7eb", color: "#111" }, }, }, anyOfVariants: [ { tone: "success", css: { fontWeight: 700 } }, { tone: "warning", css: { fontWeight: 700 } }, { tone: "danger", css: { fontWeight: 700 } }, ], });
// /components/badge.css.ts import { styled } from "@salty-css/react/styled"; export const Badge = styled("span", { base: { display: "inline-block", padding: "2px 8px", borderRadius: "999px", fontSize: "0.75rem", }, variants: { tone: { success: { background: "#16a34a", color: "white" }, warning: { background: "#eab308", color: "black" }, danger: { background: "#dc2626", color: "white" }, neutral: { background: "#e5e7eb", color: "#111" }, }, }, // Apply the same "loud" weight whenever the tone is success, warning, OR danger. // anyOfVariants uses :where(), so it loses cleanly to a more specific rule. anyOfVariants: [ { tone: "success", css: { fontWeight: 700 } }, { tone: "warning", css: { fontWeight: 700 } }, { tone: "danger", css: { fontWeight: 700 } }, ], });
<> <Badge tone="success">Saved</Badge> <Badge tone="neutral">Idle</Badge> </>
Variant props on the rendered component
Every variant name you declare becomes a typed prop on the component:
<Button variant="solid" size="large" loading> Sign in </Button>
If the consumer omits a variant prop and you declared a defaultVariants entry for it, the default applies. Variant props are consumed by Salty and do not reach the underlying DOM element by default — this is the safe default, so boolean variants like loading or disabled don't leak to the DOM as unknown attributes. See passProps when you need them forwarded (e.g. wrapping a third-party link component).