Version 0.4.0 now released! See the release notes on GitHub Releases

→ Discover the styled API and how to compose atoms with it.

Introduction to Styled Components

styled is where you'll spend most of your time in Salty CSS. You hand it a tag and a style object, and you get back a typed component whose variants are props.

If export const Card = styled("div" already lives in your muscle memory from Stitches or Panda, the shape won't surprise you — same API in spirit, with a handful of nuances of its own.

What the API is shaped around

Four ideas explain most of why styled looks the way it does. None of them are required reading — the code below works either way — but they're the difference between using the API and using it well. Design philosophy is the long version of all four.

What shaped it. Stitches was the original spark: a typed, variant-driven styled API that was simply a joy to use. When it was discontinued, the ground had already shifted underneath it — React Server Components and server-first frameworks put real pressure on runtime CSS-in-JS, where theming leans on Context and styles get injected during render. In a tree where much of your markup never touches a client runtime, that's a liability. So Salty keeps the component-first styled API but executes your style files at build time rather than statically analyzing them, and leans on native browser features for theming and overrides. What reaches the browser is a stylesheet.

Build components, not class names. The styled + TypeScript combination lets you build design-system atoms directly — not a class you then wrap in a component by hand, but the component itself. These are presentational: dumb, stateless, knowing only how they look and what states they can be in, and nothing about where their data comes from. The contract is the point. Import a styled component and your editor already knows its tag, its variants and its props; a class name is a string with no contract and no autocomplete. Salty does export className() for the cases where a string is what you need — third-party DOM, a framework without a styled — and it's a fair escape hatch rather than a lesser one. It's just not the default.

Variants as layers. Variants usually get explained as a way to branch CSS on a prop. The more useful way to hold them is as layers: base is the part that's fixed and true no matter what, variants are the named axes of variation stacked on top — size, intent, tone — and the states your component can actually be in are the combinations of those axes. You think in axes, not in one-off branches. This has a practical consequence you'll meet in Example 2: extending a component is the same move as writing a rule in a higher cascade layer, so the outer component wins without a specificity fight.

Primitives over pre-baked UI kits. Salty is not a UI kit and won't hand you a styled <Button>. Pre-baked kits are great for prototyping and tend to become a liability the moment you have to match a bespoke design exactly — overriding an opinionated component often costs more CSS than building it would have. If a kit is genuinely what you want, that's a fair call. Salty is the layer underneath, for building your own: the atoms are yours, and the fluid clamps, color math and templates you'd otherwise pull in as separate dependencies come with it.

Deconstructing styled component parameters

Every styled(...) follows the same shape. Here's the smallest form of it — a card, with just base styles and one variant axis. The file has to end in .css.ts (or one of Salty's other recognized suffixes) so the compiler picks it up at build time; a plain .ts file with the same content compiles to nothing.

components/card.css.ts
import { styled } from "@salty-css/react/styled";

// First arg: the tag to render. Can also be another component.
// Second arg: the options object.
export const Card = styled("div", {
  // Styles every instance gets. Full CSS-in-JS: pseudos, nesting with &,
  // media queries, tokens, templates.
  base: {
    padding: "1.5rem",
    borderRadius: "12px",
    backgroundColor: "white",
    border: "1px solid #eaeaea",
    transition: "box-shadow 150ms ease",
    "&:hover": { boxShadow: "0 8px 24px rgba(0, 0, 0, 0.08)" },
  },
  // Prop-driven branches. Each key becomes a typed JSX prop.
  variants: {
    tone: {
      warning: { borderColor: "#f5a524", backgroundColor: "#fff8e6" },
    },
  },
});

Using it is the boring part, which is the point:

Example
import { Card } from "./card.css";

<Card tone="warning">…</Card>;

tone becomes a typed prop on Card. Everything else the underlying <div> accepts (id, aria-*, onClick, ref, className, …) still works normally alongside it. Variant props like tone are consumed by Salty for styling and don't reach the DOM — the rendered <div> doesn't get a stray tone="warning" attribute.

Two small extras you'll reach for pretty soon:

  • defaultVariants: { … } — which branch applies when the consumer omits the prop.
  • element: "section" — render a different HTML tag while keeping the styling. <Card as="section"> is the per-instance version.

The rest of the options — compoundVariants, anyOfVariants, defaultProps, passProps, priority, className, displayName — live on the styled reference page, and you'll meet a couple of them in Example 2 below.

styled or className?

styled earns its keep by absorbing the atomic-design-system boilerplate. Building the same atoms out of class names is perfectly doable — most of us have done it plenty — it's just that every atom then needs its own wrapper function, its own prop-to-class-name mapping and its own HTML-attribute forwarding. styled folds all of that into one call.

Reach for className() instead when there's no component to build: markup you don't own, a third-party widget that only exposes a class attribute, or a framework Salty doesn't publish a styled for. Same styling surface, same compiled output — you just apply the string yourself.

Example 1 — Composing atoms

Card on its own isn't much — a padded box. In practice, atomic design is a set of small components that assume nothing about each other and compose into whatever you need. A card usually needs a title and a body, so each of those is its own atom:

components/card.css.ts (continued)
export const CardTitle = styled("h3", {
  base: {
    margin: 0,
    fontSize: "1.125rem",
    fontWeight: 600,
  },
});

export const CardBody = styled("p", {
  base: {
    margin: 0,
    fontSize: "0.9375rem",
    lineHeight: 1.5,
    color: "#555",
  },
});
Example
import { Card, CardTitle, CardBody } from "./card.css";

export const Notice = () => (
  <Card tone="warning">
    <CardTitle>Heads up</CardTitle>
    <CardBody>Your session will expire in five minutes.</CardBody>
  </Card>
);

Card, CardTitle, and CardBody are independent atoms. No shared context, no prop-drilling to style children, no wrapper element that has to be rendered for the styles to apply. Each owns its own tag, its own class, its own contract — you compose them the same way you'd compose any React tree, and the resulting HTML looks like HTML.

That's the shape of an atomic system in Salty: small typed components you build once and combine freely. Which sets up the next question — what happens when one of those atoms needs a close variant of itself?

Example 2 — Extending components

Sometimes an atom needs a cousin: same shape and structure, one more behavior. Wrapping an existing styled component gives you exactly that — a new component that inherits the original and layers changes on top:

components/card.css.ts (continued)
export const InteractiveCard = styled(Card, {
  base: {
    cursor: "pointer",
    transition: "transform 150ms ease, box-shadow 150ms ease",
    "&:hover": { transform: "translateY(-2px)" },
  },
  // A new variant sits alongside the ones Card already had.
  variants: {
    size: {
      compact: { padding: "1rem" },
      spacious: { padding: "2rem" },
    },
  },
  defaultVariants: { size: "spacious" },
  // Native HTML attributes bound to the component — different mechanism
  // from defaultVariants; this is markup, not styling.
  defaultProps: { role: "button", tabIndex: 0 },
});

Two things happen here that would be genuinely fiddly with class names alone.

First, InteractiveCard keeps Card's tone variant. The outer layer inherits everything the inner one exposed, so <InteractiveCard tone="warning" size="compact"> typechecks and works. You didn't have to redeclare, re-map, or forward anything.

Second, wrapping automatically bumps the outer component into the next cascade layer. InteractiveCard's hover reliably wins against Card's hover without a specificity fight, source-order dance, or !important. That's what "variants as layers" means in practice: extending a component is the same as writing a rule at a higher layer, and the compiler handles the ordering for you.

Wrapping third-party components (which brings its own rules — they have to accept a className prop), forwarding props with passProps, per-instance overrides via style or CSS custom properties, and typed css-* prop tokens for fully dynamic values all live one page deeper — that's Composing components, overrides & scoping.