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

The headspace the styled API puts you in — not its option list

Thinking in styled

When the ecosystem realized that runtime CSS-in-JS was too heavy for server-first architectures, the pendulum swung hard the other way — a mass migration toward utility classes and raw string generators. But the community conflated two different things. The problem was the runtime cost, not the developer experience. The component-first styled API that Stitches, Emotion, and styled-components pioneered was never the thing dragging performance down; it was the cleanest way anyone had found to build a design system. Salty brings that API back and pays the cost at build time instead.

This page isn't a tour of that API. The full option list lives in the styled docs, and what actually happens at compile time is on the Compiler and Scoping & Specificity pages. This page is about the way of thinking the API rewards — building components as typed atoms, layering their states with variants, and choosing deliberately how you compose them. The how lives elsewhere; this is the why.

Build components, not class names

Salty does export a className() function that produces a plain string. It's a fair, framework-agnostic escape hatch — port core styles to an unsupported framework like Angular, or wire up a gnarly third-party DOM structure where you only have a class attribute to work with. Reach for it when you need it.

But the primary API is the styled() component factory, and that's a deliberate choice. The styled + TypeScript combination lets you build your design-system atoms directly. Not "a class you then have to wrap in a component by hand" — the component itself. These are presentational components: dumb, stateless, knowing only how they look and what states they can be in, and nothing about where their data comes from. Container components compose them. That split — presentational atoms underneath, containers wiring them to data on top — is the shape design-led work tends to take, and styled is what makes the bottom layer cheap to build and safe to reuse.

The contract is the point. A styled component is a typed, discoverable thing: import it and your IDE already knows its tag, its variants, and its props. A class name is just a string — no contract, no autocomplete, nothing that tells the next person what's safe to pass. Building atoms as components is how you stop hand-rolling that contract every time.

A component also owns its own semantics. The first argument to styled is the tag it renders, but element lets you style one thing and render another — a <section> styled like a div — and the same element (aliased as as) can swap the rendered tag per instance at the call site. The atom decides what it is; the call site can override it when a specific spot needs different markup.

There's a subtler difference between styled and className than "component versus string," and it's worth knowing because it changes what ships. A className is generally a single generated class — one hash, one bundle of declarations. A styled component usually is too. But depending on how you build it, styled can reference a shared class rather than re-emitting its declarations into its own hash. The clearest case is templates: when a component uses textStyle: "heading.large", the template's styles can be attached as their own class and reused as an atom, instead of being inlined into every component that reaches for them. Same look on the element, a lighter stylesheet behind it — the low-fat mayo of CSS reuse.

Variants as layers

Variants usually get explained as an API feature — a way to branch CSS on a prop — and the variants docs cover them exactly that way. The more useful way to hold them is in layers. The base is the part of the component that's fixed — true no matter what. Variants are the named axes of variation layered on top — size, intent, tone — and the states a component can actually be in are the combinations of those axes. You think in axes, not in one-off CSS branches.

One way to talk about that split is atoms and molecules: a fixed lower layer, and a composed upper layer built from it that you actually style against. If you've read the Theming page, that'll sound familiar — it reaches for the same atoms-and-molecules picture for tokens. The two aren't a shared spec that agreed on terms; it's just that the same way of thinking keeps working once you're layering anything. Take the labels as a handy way to describe it, not a rule carved anywhere.

export const Button = styled("button", {
  base: {
    display: "inline-flex",
    border: "1px solid currentColor",
    cursor: "pointer",
  },
  variants: {
    intent: {
      neutral: {},
      danger: { color: "{theme.danger}" },
    },
    size: {
      small: { fontSize: "0.8em", padding: "0.4em 0.8em" },
      large: { fontSize: "1.2em", padding: "0.8em 1.6em" },
    },
  },
  defaultVariants: { intent: "neutral", size: "small" },
});

defaultVariants is the resting state — the branch applied when the consumer says nothing. compoundVariants and anyOfVariants are how you express relationships between the layers rather than separate features to learn: compoundVariants fires on an exact combination (intent danger and size large), while anyOfVariants is the OR gate — shared styles across several triggers (intent warning or danger get the same bold border). Because the compiler wraps anyOfVariants rules in :where(), they carry zero specificity, so a direct variants rule always wins over the broad one without a fight. The mechanics of that are on the Scoping & Specificity page; here it's enough to know the broad layer never blocks the specific one.

A few habits keep the layering honest:

  • Variants are for discrete states, not continuous values. A handful of named sizes is a variant axis. A user-supplied hex color is not — that's a runtime value, and it crosses a boundary (below) rather than living as a variant.
  • Add an axis when variation recurs, not for every one-off. A real size axis used across the design system earns its keep. A variant that exists for a single special-case screen is usually a sign the styling belongs at the call site or in an extension instead.
  • Keep axes orthogonal. size and intent shouldn't secretly depend on each other. When two axes genuinely interact, that's exactly what compoundVariants is for — not a reason to fold them into one tangled axis.

That boundary the first habit points at: Salty extracts styles at build time, so a style block can't read a value that only exists at runtime in the browser. For genuinely dynamic, per-instance values, you reference a placeholder like {props.bgColor}, which exposes a typed css-bg-color prop and injects it as a CSS custom property under the hood. You get the flexibility of an inline value while keeping a strict, type-checked contract — without handing the consumer a raw style={{}} object. Discrete states stay variants; continuous values cross into typed prop tokens.

Composing: variants, extension, or variables

Layering a component's variation isn't one technique. There are three, each valid, each with different DX and a different compiled result. Which one fits comes down to how you want the component consumed — as a prop, as an import, or as a context.

Variants keep everything inside one component and select with a prop. One Button, many states, chosen at the call site. Best when it's genuinely the same thing wearing different states.

Extension produces new, distinct components from a base. styled(Base, { ... }) gives you a real component you import by name, not a prop value. The pattern looks like this:

export const Heading = styled("h2", {
  className: "heading",
  base: {
    width: "fit-content",
    "&:first-child": { marginTop: 0 },
  },
  variants: {
    underlined: {
      true: { borderBottom: "2px solid", borderColor: "{theme.altBackground}" },
    },
  },
});

export const HeadingSmall = styled(Heading, {
  base: { textStyle: "headline.small" },
});
export const HeadingRegular = styled(Heading, {
  base: { textStyle: "headline.regular" },
});
export const HeadingLarge = styled(Heading, {
  base: { textStyle: "headline.large" },
});

One base Heading carries the shared base and the shared underlined variant; each size is its own named component that adds only what differs. Best when you want distinct, importable members of a family — and when callers should pick HeadingLarge, not remember <Heading size="large">.

Variables push the variation out of the component entirely and into tokens, so the same styles resolve differently by context. The component reads {theme.bg} and never changes; the surrounding scheme decides what it means. Best when the variation is really about values, not structure — which is the whole subject of the Theming page.

Same layering, three outcomes. A size axis, a HeadingLarge, and a density token can all express "this is the bigger one"; they just hand the decision to different places. Pick by how you want people to reach for it.

Extending Salty components vs. external ones

Extension behaves differently depending on what you wrap, and the difference is worth understanding.

When the thing you extend is a Salty component, the compiler knows it. It auto-bumps your extension into the next cascade layer, so the new styles reliably win over the base without any specificity guesswork, and the base's variants carry through to the new component. (The layer mechanics are on the Scoping & Specificity page.)

When you extend an external component, the one hard requirement is that it accepts a className prop — without it, the generated styles have nowhere to land. Rather than evaluating the external module at build time (importing Next.js's Link directly was the original breaking case — heavy enough to crash the compiler), Salty swaps the reference for a neutral tag internally and only lets the real component appear at runtime, where it receives the className and applies it normally.

import Link from "next/link";

export const CardLink = styled(Link, {
  base: { display: "block", textDecoration: "none" },
});

This is also where passProps earns its place. By default, variant props are kept strictly local: a disabled variant selects CSS and is then dropped, so it never lands on the DOM as a stray attribute. But when you're wrapping a component — especially an external one — you sometimes want those variant props to actually reach the underlying element. Opt in with passProps: true and the component's variant props are forwarded as a group. The usual reason is HTML-attribute parity: a variant named disabled or required that you genuinely want on the real element, not just used to pick a style branch. (Ordinary non-variant props like href or id are never swallowed in the first place — pass them at the call site, or bind them with defaultProps.)

The DX that makes it worth living in

A mindset is only worth adopting if it's pleasant day to day, and this is where Salty earns the trade. The typing goes well past basic property suggestions:

  • Token autocomplete. Anywhere a value is accepted, type { and your IDE suggests your exact token paths ({colors.brand.main}). They're validated at build time, so a typo is a compiler error, not a silent visual bug.
  • Named media queries. Reference the aliases from your config directly as object keys ('@tabletDown': { ... }) instead of writing raw queries.
  • Template resolution. Define a typography system with defineTemplates and your IDE autocompletes the valid paths (textStyle: "heading.large") right inside your style objects.
  • Component-instance autocomplete. Import an atom and its JSX element already knows its variants — they're strict, typed union props.

In a large codebase these stop being quality-of-life niceties and start cutting real cognitive load and documentation lookups.

The ESLint companion

Type safety only covers what the type system can see. For the rest, Salty ships a companion ESLint plugin that enforces the build-time rules TypeScript can't express on its own.

The most important rule is structural: styled, className, keyframes, and the other Salty functions have to live in .css.ts or .css.tsx files, because that's what lets the compiler run them ahead of time. The plugin also checks that any component you extend accepts a className prop — the same requirement the extension section leans on — and it catches a few Salty-specific mistakes that otherwise fail in ways that are hard to diagnose. Two that happened in the wild:

  • Forgetting to export a styled component. esbuild silently ignores unexported components, so the build error just looks like the component is missing — even though it's right there. The rule surfaces the real problem immediately.
  • Writing variants inside base instead of alongside it. This is valid CSS-in-JS syntax, so the parser assumes you want to target a child element literally named <variants>. The output is syntactically correct CSS doing entirely the wrong thing. The rule catches the structural mistake before it reaches the browser.

Where a fix is mechanical, the rule ships with an autofix.


Where to go next

Related recipe: status badges whose shared rules never block local overrides.