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

→ How styles stay put, and how to change them deliberately when you want to.

Scoping and composition

Scoping has two jobs, and they pull in opposite directions. The first is isolation — your styles stay on the thing you wrote them for and don't leak three components away. The second is overriding — when you do want to change something, you can, deliberately, without a pile of selectors fighting each other for the last word.

Salty handles the first for you: every styled component and className gets a hashed class, so its rules can't wander off. This page is mostly about the second — how you reach into a scoped component on purpose, how you compose components together, and how the cascade decides who wins when two rules want the same property. The why behind all of it lives on the Scoping & Specificity concept.

None of this is new, either — it's the scoping you already half-know from every stylesheet you've written. Think of the page as finally reading the IKEA manual for the shelf you were sure you could build from the picture on the box: the steps were never hard, but quietly skipping a couple is behind a fair number of the CSS bugs you've sworn at over the years.

Everything below is authored in a .css.ts file (or one of Salty's other recognized suffixes) and compiled to a static stylesheet at build time. None of it ships a styling runtime; the only JavaScript involved is whatever you already write to set a prop or an attribute.

Nesting: pseudo-classes, children, and states — already scoped

The first thing to know is that scoping doesn't cost you the CSS you already know. If you've written nested SCSS, this will feel like home: & is the current component, and everything you'd nest under it — pseudo-classes, combinators, child selectors, attribute selectors — works exactly the same. The difference is what happens at compile time.

How you write it

Nest freely. & is the component's own hashed class; every selector you write hangs off it:

components/menu.css.ts
import { styled } from "@salty-css/astro/styled";

export const Menu = styled("nav", {
  base: {
    padding: "0.5rem",

    // pseudo-classes on the component itself
    "&:hover": { background: "{colors.grey.light}" },
    "&:not(:focus-within):hover": { background: "{colors.grey.lighter}" },

    // a direct-child combinator, same as plain CSS
    "& > svg": { width: "1rem", opacity: 0.7 },

    // style a descendant, and react to the parent's state from inside it
    "& a": { opacity: 0.7, transition: "opacity 150ms ease" },
    "&:hover a": { opacity: 1 },

    // an attribute already in your markup doubles as a styling hook
    "& a[aria-current='page']": { opacity: 1, fontWeight: 700 },
  },
});

How it compiles — the part that matters for scoping

Here's the payoff. Every one of those selectors comes out anchored to the component's hash. & a doesn't compile to a global nav a that could reach into some unrelated navigation elsewhere — it compiles to this component's hash plus a, and nothing else:

saltygen/index.css
/* illustrative — the real hash is generated, e.g. .kBvRn */
.kBvRn { padding: 0.5rem; }
.kBvRn:hover { background: … }
.kBvRn > svg { width: 1rem; opacity: 0.7; }
.kBvRn a { opacity: 0.7; }
.kBvRn:hover a { opacity: 1; }

That's the whole isolation guarantee in one line: because the hash is on the front of every rule, & a physically can't match a link that isn't inside this Menu. You get SCSS-style nesting ergonomics without the SCSS-style leak risk — nesting in plain global CSS gives you the same syntax but none of that containment.

The same anchoring covers the more powerful modern selectors — &:has(...) to style a parent based on what it contains, & input:user-invalid for a self-flagging field, nested @media and @container queries. They're all scoped the same way, and the Interactive State basics page is the deeper tour of what the browser will track and style for you before you reach for a single prop. className() supports the exact same nesting when you want a raw class string instead of a component.

Targeting another component by identity

Nesting with a bare tag (& a, & > svg) is fine when the target is a plain element. But when the thing inside is another Salty component, targeting it by tag is brittle — the tag can change, and two different components might share one. Salty lets you target by identity instead: a styled component and a className both expose their underlying hash as a string, so you can interpolate them straight into a selector.

How you define it

Import the component and drop it into the selector key:

components/button.css.ts
import { styled } from "@salty-css/astro/styled";
import { Icon } from "./icon.css";

export const Button = styled("button", {
  base: {
    padding: "0.6em 1.2em",
    display: "inline-flex",
    gap: "0.5em",

    // target the Icon component specifically, by identity, not by tag
    [`& ${Icon}`]: { opacity: 0.8, transition: "opacity 150ms ease" },
    "&:hover": {
      [`& ${Icon}`]: { opacity: 1 },
    },
  },
});

How you use it

Nothing special at the call site — you just compose the two components as normal, and the contextual styling applies because Button knows Icon's identity:

Example
---
import { Button } from "../components/button.css";
import { Icon } from "../components/icon.css";
---

<Button>
  <Icon name="download" /> Download
</Button>

This is styling by contract rather than by DOM shape: Button says "any Icon inside me dims to 0.8, and lights up on hover," and it keeps working even if Icon later renders a different tag or gets wrapped in a span. No untyped .icon class to keep in sync, no dependence on markup structure you don't fully control.

A shared class for a whole family of components

Hash interpolation targets one component by identity. Often you want the opposite — a single class that a whole family of components carries, so you can reach all of them at once. The everyday case is styling every heading inside a text chapter. Targeting & h2 is the fragile way to do it: it misses the headings you rendered as h3, and it catches stray <h2>s that aren't yours. A shared, opt-in class is the precise way — and the styled API supports adding one right in the definition.

How you define it

The className option appends a class of your choosing alongside the generated hash. Give every heading the same one:

components/heading.css.ts
import { styled } from "@salty-css/astro/styled";

export const Heading = styled("h2", {
  className: "chapter-heading", // shared hook, on every Heading instance
  base: { fontWeight: 700, lineHeight: 1.2 },
});

Every instance now renders both classes — the encapsulated hash and your stable one:

Example
<h2 class="qFtNb chapter-heading">…</h2>

How you use it

Now a parent can style the whole family in one place. Because the selector is nested inside a scoped component, it stays anchored to that component's hash — so it only reaches the headings inside this chapter, never every .chapter-heading in the app:

components/chapter.css.ts
import { styled } from "@salty-css/astro/styled";

export const Chapter = styled("section", {
  base: {
    lineHeight: 1.6,
    // every Heading in this chapter, by its shared class — scoped to this section
    "& .chapter-heading": { marginTop: "2em", scrollMarginTop: "5vh" },
  },
});

That's the everyday reason to reach for it: styling a set of related elements together from their common ancestor, without leaning on tag names or re-listing each component. It's the natural partner to the interpolation trick above — use a component's hash when you want one specific component, and a shared class when you want a family that opts into the same name.

The same class earns its keep outside your styles too. It survives extension and any consumer classes appended after it, which makes it a reliable hook for an end-to-end test, an analytics selector, or a legacy global stylesheet you're integrating with — none of which should be guessing a hash that's meant to change. className() takes the same option when you're working with class strings.

One honest boundary. Styling this shared class from a scoped parent, like Chapter above, is completely fine — the parent's hash keeps the rule contained to its own subtree. The thing to avoid is reaching for the same class from an unscoped global stylesheet to restyle components you own, which drops you back into the leaky global-selector world Salty exists to avoid. The class is identical; the only difference is whether a hash is anchoring the rule. Inside a component, style away; from a bare global, keep it to finding elements rather than restyling them.

Changing a component on purpose

Now the deliberate-override side. You have a component, and you want a specific instance — or a specific mode — to look different. There's a short ladder here, and it's worth climbing in order, because the lower rungs are the ones that stay predictable as the app grows.

Rung one: a variant, when the change is a named mode. If the difference is part of the component's contract — a tone, a size, an emphasis — it's a variant, not an override. You're not fighting the component; you're extending its vocabulary:

Example
export const Card = styled("article", {
  base: { padding: "1rem", borderRadius: "8px", border: "1px solid #eaeaea" },
  variants: {
    emphasis: {
      normal: {},
      raised: { boxShadow: "0 8px 24px rgba(0,0,0,0.08)", borderColor: "transparent" },
    },
  },
  defaultVariants: { emphasis: "normal" },
});
// <Card emphasis="raised">…</Card>

Rung two: the style prop, for a genuine one-off. When exactly one instance needs a tweak that will never be reused, a plain inline style is the honest tool — no variant to name, no rule to add to the stylesheet:

Example
<Card style=undefined>…</Card>

An inline style is a normal inline declaration, so it outranks any normal layered rule setting the same property — that's why this reliably takes, without a specificity fight.

Rung three: a CSS custom property, when the knob should be reusable but doesn't deserve a full variant. Declare the variable with a fallback inside the component, then let any consumer set it per instance through the style prop:

Example
export const Card = styled("article", {
  base: {
    padding: "1rem",
    // read a variable, fall back to a sensible default
    borderColor: "var(--card-edge, #eaeaea)",
  },
});
Example
// consumer drives the internal without knowing anything about the cascade
<Card style=--card-edge": "dodgerblue>…</Card>

This is the escape hatch that never fights the cascade. You're not overriding the rule that sets border-color — you're feeding a value into the rule that already wins, which is why it's reliable regardless of layers or specificity. When you'd rather expose a typed, discoverable prop than ask consumers to remember a variable name, the same mechanism has a nicer front door — {props.X} tokens, covered on the Dynamic Values page.

You can also just pass your own className at the call site — the underlying element accepts one like any React element — which is handy for wiring in an existing external class on a single instance. Be deliberate about it, though: a hand-written class living outside Salty's layers plays by different cascade rules than a styled rule does, so treat it as an integration hook rather than your default way to restyle. For restyling something you own, the ladder above stays predictable; a stray external class is the kind of thing that wins today and mysteriously loses next refactor.

Composition: extend a component, get the layer bump for free

Overriding a single instance is one thing; making a new component that's a close cousin of an existing one is composition, and it's where Salty's layer model earns its keep. Wrap a styled component in another styled call and you get a new component that inherits everything the original exposed and layers changes on top.

How you define it

Pass the component (not a tag) as the first argument:

Example
// components/button.css.ts
export const Button = styled("button", {
  base: { padding: "0.6em 1.2em", background: "{colors.grey.main}", cursor: "pointer" },
  variants: {
    size: { small: { fontSize: "0.8em" }, large: { fontSize: "1.2em" } },
  },
});

// components/primary-button.css.ts
import { Button } from "./button.css";

export const PrimaryButton = styled(Button, {
  base: { background: "{colors.brand.main}", color: "white" },
});

How you use it

Example
// PrimaryButton kept Button's `size` variant for free
<PrimaryButton size="large">Save</PrimaryButton>

Two things happen here that are genuinely fiddly to do by hand:

First, PrimaryButton inherits Button's size variant — you didn't redeclare, re-map, or forward anything. The typed prop just carries through.

Second — and this is the scoping payoff — wrapping automatically bumps the new component into the next cascade layer. Button lands in layer l0; PrimaryButton lands in l1. So PrimaryButton's blue background reliably wins over Button's grey one by layer order, with no specificity trick, no source-order gamble, and no !important. Extending a component and writing a rule at a higher layer are the same operation; the compiler just does the bookkeeping.

One thing to watch when you extend: if a variant name collides with a real HTML attribute — disabled, open, checked, href — remember that variant props are consumed by Salty and don't reach the DOM by default. When you're wrapping something that needs the real attribute to function (a router link that needs href, a button you want genuinely disabled), forward it with passProps:

Example
import NextLink from "next/link";

export const Link = styled(NextLink, {
  passProps: ["href", "prefetch"], // forward these to the wrapped component
  base: { color: "{colors.brand.main}" },
});

passProps: true forwards all variant props; a string or array forwards only the ones you name. The full mechanics — extending third-party components, element vs as, the disabled-as-a-variant trap — live on the styled reference page.

How precedence is actually decided: cascade layers vs !important

Everything above rests on one mechanism, so it's worth seeing plainly. In traditional CSS, when two rules target the same property at the same specificity, source order breaks the tie — and in a bundled, code-split app you have almost no control over source order. That's where a lot of "why won't this override apply" afternoons go.

Salty sidesteps it with native cascade layers (@layer). Every rule is emitted into a fixed hierarchy:

Example
@layer imports, reset, global, templates, fonts, l0…l8;

A rule in l1 beats a rule in l0 on layer order alone — even when the l0 selector is technically more specific. Layer order outranks specificity, which is exactly why extension (auto-bumped to a higher layer) and priority work without selector inflation. Think of priority as z-index for the cascade: a plain component sits in l0, and bumping priority moves it up:

Example
export const UtilityOverride = styled("div", {
  priority: 2, // forces this rule into @layer l2
  base: { color: "red" },
});

That's the clean way to resolve a stubborn tie — including the occasional hash-collision regression, where two rules end up in the same layer and bundler ordering decides the winner. Bump the priority and the tie is settled deterministically.

The !important trap

Salty is pragmatic about !important: it doesn't strip it, warn on it, or rewrite it. Write it and the compiler emits it. But there's one genuinely counterintuitive rule of native CSS you have to know before you reach for it — !important reverses layer precedence.

Normally l1 beats l0. But if both rules use !important, the earlier layer (l0) wins instead. The whole ordering flips. That inversion breaks most people's mental model and produces some of the most baffling cascade bugs there are — you add !important to force a rule through, and it makes the wrong rule win. It's the one place in CSS where trying harder makes you lose.

So the honest guidance: inside the layer model you almost never need !important. To win a tie, raise priority or extend the component. To override one property on one instance, use the style prop or a CSS custom property — a normal inline declaration already outranks any normal layered rule, so you get your override by feeding the rule that wins rather than escalating a war on the property. (To be precise, an inline style isn't unbeatable: an !important layered rule still outranks it, and an inline !important beats that — but inside the layer model you rarely need to go there.)