Version 0.2.0 released! Check out the release notes from GitHub Releases

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).