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

How Salty CSS Makes the Specificity War Winnable with Native Cascade Layers and Deterministic Hashing

Scoping & Specificity

A lot of experienced developers have a rough relationship with CSS — some will happily tell you it's their least favorite part of the job. There's no shortage of reasons for that, but in a lot of the cases I've dug into, a surprising amount of the friction traces back to one corner of CSS in particular: scoping. A style leaks somewhere it shouldn't, an override quietly refuses to take, and a perfectly good engineer ends up stacking selectors and !important until something finally sticks.

The basics of scoping are genuinely easy to learn. Doing it consistently — across a real codebase, under a deadline, with components that get extended and reused by other people — is the part that bites. The mistakes are quiet, and they compound.

So this page isn't about how to use flex or grid. It's about how Salty makes isolation the default and the most common scoping mistakes harder to make by accident — you still make the calls; the tooling just keeps more of them from quietly going wrong. There are two things to get right. First, isolation — your styles shouldn't leak out and break something three components away. Second, overrides — you should be able to deliberately change a component without an escalating pile of selectors fighting each other for the last word.

People have called this the specificity war for years, and for years it earned the name. Native cascade layers are what finally make it winnable — you decide the outcome up front, deterministically, instead of fighting source order. The war framing stays; the ending changes.

The ecosystem has taken a few different routes to this. CSS Modules and Vanilla Extract gave us strict local scoping — solid, but reaching back out to a global element or a nested child can get rigid. Panda CSS brought powerful build-time extraction, with more explicit boilerplate (like & prefixes) as part of the deal. These are fair tradeoffs; they're just drawn in different places than I needed them drawn.

Salty pairs the fluid selector ergonomics of Stitches with the architectural certainty of native CSS Cascade Layers. The rest of this page is how that actually works.

The purest example of scoping pain I know is overriding a pre-baked, heavily opinionated UI kit — and I say that as someone who did it the hard way. Back in 2018 and 2019 I leaned on Angular Material and wrote a pile of override styles to bend its components into the design I actually wanted. The honest takeaway, years later: this wasn't really Material's fault. Most of that effort went into scoping overrides onto buttons I didn't fully control, when a simple button with a class of my own would have been faster and far less fragile. I've watched the same pattern since with longer-career senior devs I've helped with MUI. The tools are fine — reaching for a heavy kit and then fighting it is the part that's a little sad, usually a kit dropped into a spot where a few primitives would have done the job. (The Design Philosophy page goes deeper on why Salty ships primitives instead of a kit.) Scoping-first components are partly a reaction to exactly that experience.

The Ergonomics of Isolation

Salty generates a deterministic, locally scoped hash class (e.g. TzHVd) for every component or class definition. But unlike older scoping solutions that make stepping outside that hash painful, Salty leans on natural, standard CSS nesting.

Natural Nesting & Responsive Scoping

Whether you're using pseudo-selectors (&:hover), chained states (&:not(:disabled):active), combinators (& > svg), or custom data attributes (&[data-state="open"]), the syntax stays fluid.

This scoping applies to responsive design too. When you nest an @media or @container query directly inside your component definition, the compiler keeps those rules strictly isolated to that element's hash.

And it isn't locked behind the styled() wrapper. If you just need a raw class string to hand to a third-party library, the lightweight className() generator supports the exact same nesting.

Scoped :has() and Other Things CSS Can Do Now

This is worth dwelling on, because it's the part people often don't realize they have. CSS has quietly become far more capable, and a lot of the logic that used to require a useEffect and a class toggle now lives in the stylesheet — fully scoped to your component.

:has() is the clearest example. It lets an element style itself based on what it contains or what sits next to it — the long-requested "parent selector," and then some. For years, "make this wrapper react to the state of something inside it" meant reaching for JavaScript or a global selector that risked leaking. Nested under a Salty hash, that same logic stays scoped:

export const Card = styled("article", {
  base: {
    padding: "1rem",
    // Only when the card actually contains an image
    "&:has(img)": {
      paddingTop: 0,
      overflow: "hidden",
    },
    // React to the state of something inside, without touching JS
    "&:has(:focus-visible)": {
      outline: "2px solid var(--focus, dodgerblue)",
    },
  },
});

The same nesting opens up &:has(input:invalid) for a field wrapper that flags itself, sibling logic with + and ~, and @container queries that respond to layout context rather than the viewport. None of it ships JavaScript, and because every selector stays anchored to the component's hash, it can't quietly match unrelated elements elsewhere in the app.

A note on support, since it's the usual worry: :has() has been Baseline since 2023 and works across every major browser. And it's worth saying plainly — Safari shipped :has() first, back in 15.4, ahead of Chrome and well ahead of Firefox. Safari catches a lot of "the new IE" jokes, but the WebKit team quietly made my life easier with :has() — and a couple more times since. Credit where it's due. 🧂

While we're on selectors people underuse, :where() is the mirror image of everything in the overrides section below. It wraps any selector and strips its specificity to zero, so the rule still applies but loses to anything else, cleanly. That's exactly what you want for shared base styles you fully intend to be overridden — no !important, no inflated selectors, just a rule that yields by design. (Salty uses it internally so some of its rules stay easily overridable; the variants docs cover where.)

Component Targeting (Hash Interpolation)

Salty treats components as first-class selectors. Because styled() components and className() functions expose their underlying hash string, you can interpolate them directly into your selectors — styling a child component contextually without leaning on brittle, untyped DOM structure:

import { Icon } from "./icon.css";

export const Button = styled("button", {
  base: {
    padding: "1rem",
    // Target the specific Salty component inside this button
    [`& ${Icon}`]: {
      opacity: 0.8,
      transition: "opacity 0.2s",
    },
    "&:hover": {
      [`& ${Icon}`]: { opacity: 1 },
    },
  },
});

Stable Classes for External Targeting

Generated hashes are great for encapsulation and terrible for external targeting or end-to-end testing.

Rather than making you guess the hash or hand-attach a class in your JSX, Salty supports a native className option right in the definition:

export const Card = styled("article", {
  className: "salty-card", // Stable class appended alongside the hash
  base: { padding: "1rem" },
});

The rendered element gets both the encapsulated hash and your custom string. That class stays stable even if a consumer extends the component or appends their own classes — a reliable hook for legacy CSS integrations, global overrides, or QA frameworks.

Predictable Overrides with Cascade Layers

Once your styles are scoped, the next thing to get right is changing them on purpose. In traditional CSS, when two selectors target the same element at the same specificity, source order decides the winner. In a bundled, chunked component architecture, guaranteeing source order is practically impossible — which is where a lot of "why won't this override apply" time goes.

Salty leans on native CSS Cascade Layers (@layer): which rule wins is decided by layer order, not by the source order your bundler happens to emit. That's what keeps overrides predictable as an app grows.

Every rule is emitted into a strict, predefined layer hierarchy:

@layer imports, reset, global, templates, fonts, l0, l1, l2, l3, l4, l5, l6, l7, 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 here; the one thing that flips it is !important (see the trap below).

Auto-Bumping: Safe Component Extension

A standard styled('button', {...}) defaults to priority 0 and lands in l0.

The useful part is extension. When you write styled(Button, {...}), the compiler detects that you're extending an existing component and bumps the new one's priority to 1, landing it in l1:

// Lands in @layer l0
export const Button = styled("button", {
  base: { background: "gray", padding: "1rem" },
});

// Lands in @layer l1 — beats Button's base styles by layer order.
export const PrimaryButton = styled(Button, {
  base: { background: "blue" },
});

You don't need to inflate a selector to force an extension to apply — layer order handles precedence, so the extending component takes priority over the one it extends.

Manual Priority Control

Sometimes you need to step in directly. Pass an explicit priority to any styled component or class-name generator to force it into a specific layer. Think of priority as z-index for the cascade.

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

Beyond high-priority utilities, explicit priority is the cleanest fix for hash collisions. In large codebases, generated hash names can occasionally shift in alphabetical source order during bundling. If a previously "winning" rule suddenly loads earlier and you get a random styling regression, bumping priority resolves the tie without resorting to hacks.

The !important Trap

Salty is pragmatic: it doesn't strip, warn on, or rewrite !important. If you write it, the compiler emits it. People use what's familiar, and sometimes you do just need to force a style through a third-party DOM boundary.

But you should know one genuinely confusing quirk of native CSS: !important inside cascade layers reverses precedence.

Normally l1 beats l0. But if both layers use !important, the earlier layer (l0) wins. That inversion breaks most people's mental model and leads to deeply confusing bugs. Prefer raising the layer priority instead.

The Better Escape Hatch: CSS Variables & the style Prop

If you need a per-instance override that reliably takes precedence without disturbing the layer architecture, use CSS custom properties. Salty treats the native React style prop as a first-class citizen, so you can pass arbitrary CSS variables to drive a component's internals:

// Inside your styled component:
// width: "var(--custom-width, 100%)"

// At the call site:
<Button style={{ "--custom-width": "50%" }}>Submit</Button>

A value passed through the inline style prop is a normal inline declaration, which outranks any normal rule setting the same property — layered or not, regardless of specificity. That's what makes driving a component through a CSS variable reliable: you're feeding a value into the rule that already wins, rather than fighting the cascade on the property itself. (To be precise, it isn't unbeatable — an !important declaration still outranks a normal inline style, and an inline !important beats that in turn. Inside the layer model you rarely need either.)

Scoping, With Better Tools

Come back to the developer from the top — the one who'll tell you CSS is their least favorite part of the job. Scoping never stops being something you think about; CSS didn't suddenly get easier to reason about. What changed is the tooling around it, and that's most of the difference between scoping that fights you and scoping that holds.

Hashing makes isolation the default instead of something you remember to wire up. Cascade layers make overrides deterministic, so the precedence you decided on is the one that ships — not whatever order the bundler happened to emit. Extension auto-bumps, so the thing you just built beats the thing you extended. And modern CSS — :has() for contextual styling, :where() for rules that yield — gives you native primitives where you used to reach for a useEffect and a tangle of global selectors. The specificity war is winnable here because the architecture decides it deterministically, not because you got better at trench warfare.

You still do the thinking — you just spend more of it on the design and less of it fighting the cascade, and the common mistakes get much harder to make by accident. The basics of scoping were always easy. Better tools are what finally make the execution easy too.