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

→ Most interactive state is not a JavaScript problem — start with what the browser already tracks.

Interactive State

Interactive state — hover, focus, a form that's mid-submit, a panel that's "in edit mode" — mostly isn't a JavaScript problem. A surprising amount of it is state the browser already tracks for you and will style for free, and for the rest, Salty gives you a short ladder of escalations so you only reach for React state when the platform genuinely can't see what you need.

The mindset underneath it, and the thing that keeps components small: climb the ladder only as far as the state forces you to. Four rungs, roughly:

  1. The browser already tracks it — hover, focus, a valid field, a checked box, a container that holds an invalid input. Style it with a pseudo-class. No prop, no state, no JavaScript. This is Example 1, and it covers more than people expect.
  2. It's app state the browser can't see, but it's a closed set — idle / submitting / error / success. Lift it to a variant and drive that variant from your React state. Example 2.
  3. It's an open-ended value, not a closed set — a progress width, a live count, a user-picked amount. That's a prop token ({props.X}), not a variant. Example 3.
  4. You want a whole region to read as a state — recolor everything under "edit mode" or "danger" at once. That's theming pointed at a state axis instead of a light/dark one. Example 4.

Everything below is authored in TypeScript in a .css.ts file and compiled to a static stylesheet at build time. Rungs 1, 2 and 4 ship no styling runtime at all — the only JavaScript in play is your own state setting a prop or an attribute. Rung 3 is the one place a value rides a live CSS variable into the browser, on purpose.

Example 1 — State the browser already tracks

Start here every time, because this rung is bigger than it looks. The browser is a state machine that's already running: it knows what's hovered, focused, checked, valid, disabled, and what any element contains. Salty's job is just to let you nest those selectors, scoped to a component's hash, with &.

The everyday ones

Hover, focus, active, disabled — the states you already reach for. Nest them in base and they apply to every instance, no prop required:

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

export const Button = styled("button", {
  base: {
    padding: "0.6em 1.2em",
    borderRadius: "6px",
    cursor: "pointer",
    transition: "background 150ms ease",
    "&:hover": { background: "{colors.grey.light}" },
    "&:active": { transform: "translateY(1px)" },
    // native disabled — the browser also stops clicks and tells assistive tech
    "&:disabled": { opacity: 0.5, cursor: "not-allowed" },
  },
});

Nothing here is a variant, because nothing here is yours to track — the browser flips these states as the user interacts, and your rule just reacts. Hold onto the &:disabled line; it comes back in Example 2 as the reason a disabled variant is usually a mistake.

The ones people forget they have

A chunk of "I'll just go wire that up myself" is a pseudo-class you didn't know had shipped. We'll memoize every callback and shave a kilobyte off a bundle, then hand-roll in JavaScript a piece of state the browser was already tracking for free. A few worth keeping in the front of your mind:

Example
export const Field = styled("label", {
  base: {
    display: "grid",
    gap: "0.25rem",

    // the field wrapper glows when anything inside it has focus
    "&:focus-within": { outline: "2px solid {colors.brand.main}" },

    // style the input, but only show validation AFTER the user has interacted —
    // :user-invalid waits for a blur/submit, so you don't scold an empty field on load
    "& input:user-invalid": { borderColor: "{colors.danger}" },
    "& input:user-valid":   { borderColor: "{colors.success}" },

    // fade the label while the input still shows its placeholder
    "& input:placeholder-shown + span": { opacity: 0.6 },
  },
});

:focus-within, :user-invalid / :user-valid, and :placeholder-shown are all Baseline-supported and let you build a form that reacts to itself with zero state. :user-invalid in particular is the fix for the classic "why is my whole form red before I've typed anything" problem — unlike plain :invalid, it only matches once the user has actually touched the field.

And it's not only pseudo-classes

State the browser tracks isn't limited to pseudo-classes. Some of it lives in plain attributes, and some elements are little state machines on their own.

<details> is the clearest example — a native open/closed disclosure with no JavaScript and no library. The browser flips the open attribute when the summary is clicked; you just style off it:

Example
export const Disclosure = styled("details", {
  base: {
    borderLeft: "3px solid transparent",
    "& summary": { cursor: "pointer", fontWeight: 600 },
    // the browser toggles [open] for you — style the open state directly
    "&[open]": { borderLeftColor: "{colors.brand.main}" },
    "&[open] summary": { marginBottom: "0.5rem" },
  },
});

The same goes for the ARIA and data- attributes already sitting in your markup. If you've set aria-expanded, aria-current, or aria-selected for assistive tech — and you should have — that attribute doubles as a free styling hook, no extra state to track:

Example
export const NavLink = styled("a", {
  base: {
    opacity: 0.7,
    // the attribute is there for screen readers anyway; style off it for free
    "&[aria-current='page']": { opacity: 1, fontWeight: 700 },
  },
});

Reacting to children, and styling into children

Two directions worth naming, because they're the ones that used to require JavaScript.

From a child's state, up to the parent — this is :has(), the long-requested "parent selector." The wrapper styles itself based on what it contains:

Example
export const Card = styled("article", {
  base: {
    padding: "1rem",
    borderRadius: "8px",
    // trim the top padding only when the card actually leads with an image
    "&:has(> img:first-child)": { paddingTop: 0, overflow: "hidden" },
    // the whole card flags itself when a field inside is invalid
    "&:has(input:user-invalid)": { borderColor: "{colors.danger}" },
    // and lifts when anything inside takes keyboard focus
    "&:has(:focus-visible)": { boxShadow: "0 0 0 3px {colors.brand.muted}" },
  },
});

From the parent's state, down into children — a parent pseudo-class driving a descendant. Nest the child selector under the state:

Example
export const Menu = styled("nav", {
  base: {
    "& a": { opacity: 0.7, transition: "opacity 150ms ease" },
    // light every link up when the nav itself is hovered
    "&:hover a": { opacity: 1 },
  },
});

Because every selector Salty emits is anchored to the component's own hash, none of this leaks — &:has(...) and & a only ever match inside this component, never some unrelated element across the app. That scoping guarantee is the whole point of the Scoping & Specificity concept, and if you need to target another Salty component by identity rather than by tag, that page covers interpolating a component's hash into a selector.

The takeaway for the rest of the ladder: before you add a prop, ask whether the browser is already tracking the thing you want to style. Often it is.

Example 2 — App state the browser can't see: lift it to a variant

Some state is genuinely yours. Whether a request is in flight, whether the last save failed — the browser has no pseudo-class for "my API call is pending." When the state is a closed set of named modes, model it as a variant axis and drive that axis from your React state.

How you define it

One axis, one value per mode. The browser-tracked states stay where they belong — in &:disabled — and the variant only carries what the browser can't know:

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

export const SubmitButton = styled("button", {
  base: {
    padding: "0.6em 1.2em",
    borderRadius: "6px",
    cursor: "pointer",
    transition: "background 150ms ease, opacity 150ms ease",
    "&:disabled": { opacity: 0.5, cursor: "not-allowed" },
  },
  variants: {
    status: {
      idle:       {},
      submitting: { opacity: 0.7, pointerEvents: "none" },
      error:      { background: "{colors.danger}",  color: "white" },
      success:    { background: "{colors.success}", color: "white" },
    },
  },
  defaultVariants: { status: "idle" },
});

How you use it

Your state hook owns the value; you pass it straight in as the prop. Note that the real disabled here is the native HTML attribute — the browser disables the button and styles it via the &:disabled from base — while status carries the app state:

Example
"use client";
import { useState } from "react";
import { SubmitButton } from "./submit-button.css";

export function SignupForm() {
  const [status, setStatus] = useState<"idle" | "submitting" | "error" | "success">("idle");

  async function handleSubmit() {
    setStatus("submitting");
    try {
      await createAccount();
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  return (
    <SubmitButton
      status={status}
      disabled={status === "submitting"}
      onClick={handleSubmit}
    >
      {status === "submitting" ? "Creating account…" : "Sign up"}
    </SubmitButton>
  );
}

The variant prop is consumed by Salty and never reaches the DOM, so there's no stray status="error" attribute on the rendered <button>. All the CSS for every branch was emitted at build time; your state just picks which class is on the element.

For a simple on/off case — a spinner overlay, a dimmed state — you don't even need a full axis. A boolean variant (loading: { true: { … } }, used as <SubmitButton loading>) is the lighter tool, and like any variant it won't leak to the DOM.

One boundary to be clear about: Salty is styling the state here, not managing the form. The async call, the validation, the custom controls — that logic is yours. Native constraint validation (the :user-invalid family from Example 1) covers a surprising amount of it for free, and when you need something more custom there's margarita-form for the wiring — still a bit of a pain, because form handling always is, but it takes the edge off. Whatever produces the state, the styling side stays exactly what you see above.

The attribute alternative

If the state doesn't live in a useState — it's coming from a state machine (XState and friends), or it's on a node you don't render through styled — key off a data attribute instead of a variant. Salty nests attribute selectors the same way it nests pseudo-classes:

Example
export const StatusBar = styled("div", {
  base: {
    padding: "0.5rem 1rem",
    '&[data-status="submitting"]': { opacity: 0.7 },
    '&[data-status="error"]':      { background: "{colors.danger}", color: "white" },
    '&[data-status="success"]':    { background: "{colors.success}", color: "white" },
  },
});
// <StatusBar data-status={machine.state} />

Same result, different source of truth — but there's a catch worth stating, because it's the reason to treat variants as the default. A &[data-status="…"] selector stays anchored to this component's hash, so it won't leak out. The problem is the other direction: data-status is a plain, unscoped DOM attribute, so if an ancestor also carries a data-status, or some broader rule elsewhere targets [data-status="error"] without a hash to pin it, the match can reach across nesting levels and paint things you didn't mean to. A variant sidesteps that entirely: each is a unique, hashed class that can only ever match the element it's on.

So the honest default is variants for styling. Reach for the data attribute when an external machine genuinely owns the state, or — the case that's usually the real motivation — when your own JavaScript needs to find these elements later. And there's no rule against doing both: style with the variant, and set a data-* attribute alongside it purely as a query handle. That combination comes back in Example 4.

The disabled trap

Here's the one that catches people, and it's a direct consequence of the ladder. Tempted by the pattern above, you might reach for a disabled variant:

Example
// ⚠️ looks reasonable, quietly does the wrong thing
variants: {
  disabled: { true: { opacity: 0.5, pointerEvents: "none" } },
},

Now <SubmitButton disabled> applies your styles — but disabled is a variant, and variant props are consumed by Salty and don't reach the DOM by default. So the rendered <button> never gets the real disabled attribute. It looks disabled while staying fully clickable, keyboard-focusable, and submittable, and assistive tech is never told it's off. You've painted a disabled button without disabling anything — buying yourself extra complexity while disabling your own best performance. (Pun very much intended.)

There are two honest fixes, and the first is almost always the right one:

  • Don't lift it at all. disabled is a state the browser already tracks — that's Rung 1. Leave it as the native attribute (disabled={…} in JSX) and style it with &:disabled in base, exactly like the working example above. The platform does the disabling; you just decorate it.
  • If you truly want it as a variant (say the styling is elaborate and you like it in the typed prop contract), add it to passProps so the prop forwards to the element and Salty styles it: passProps: ["disabled"]. Now both the attribute and the styles fire.

There's a real exception, though, and it's worth knowing because the web is what it is: the native disabled attribute only exists on form controls<button>, <input>, <select>, <textarea>, <fieldset>. A link, or a <div> you've pressed into service as a control, has no native disabled to forward; there's nothing to lose by making it a variant. In that case a disabled variant is a perfectly proper choice — just pair it with aria-disabled="true" (and skip the click handler / tab index yourself) so the state is still announced, since the browser won't do it for you here.

The general rule the trap illustrates: when a variant name collides with a real HTML attribute (disabled, hidden, open, checked), decide deliberately whether the DOM attribute should still fire, and forward it if so. When the browser already tracks the state, prefer letting it keep it; when it genuinely can't (a non-form element), own it yourself and wire up the ARIA. Either way the Accessibility page covers why the native attribute matters beyond styling.

Example 3 — Open-ended state values with prop tokens

Variants are for closed sets. The moment the state is a continuous, open-ended value — a progress percentage, a live volume level, a countdown — a variant stops making sense: you'd be declaring progress: { "0": …, "1": …, "2": … } forever. That's the case for a prop token.

How you define it

Reference {props.X} anywhere a value goes, and the compiler exposes a typed css-X prop and wires it through a CSS variable for you:

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

export const ProgressFill = styled("div", {
  base: {
    height: "8px",
    borderRadius: "999px",
    background: "{colors.brand.main}",
    transition: "width 200ms ease",
    // open-ended, per-instance — a prop token, not a variant
    width: "{props.value}",
  },
});

How you use it

The value comes straight off your state and updates live — no new class, no re-styling, just a custom property changing:

Example
"use client";
import { useState } from "react";
import { ProgressFill } from "./progress-fill.css";

export function Uploader() {
  const [pct, setPct] = useState(0);
  // setPct(...) as the upload reports progress
  return <ProgressFill css-value={`${pct}%`} />;
}

Under the hood Salty writes css-value to the element's inline style as --props-value, and your compiled rule already reads it via var(--props-value). A couple of things to keep straight: the token is camelCase ({props.fillColor}), the JSX prop is its dash-cased twin (css-fill-color), and an unset prop writes nothing — so pair the token with a fallback when you want a resting default. The full mechanics (fallbacks, the style=undefined hand-rolled equivalent, and when to prefer each) live on the Dynamic Values page — this is the same {props.X} path, aimed at state that changes over time rather than a value picked once.

This is the one rung where styling data genuinely reaches the browser: the value rides a real CSS variable to runtime instead of being baked into the stylesheet. That's exactly what you want for a number that changes per frame — and exactly what you don't need for the other three rungs, which resolve to static classes.

Example 4 — Theming as perceived state

Sometimes the state isn't one element's — it's a whole region's. "This form has unsaved changes," "this panel is in danger mode," "the app is in focus/reading mode." You could thread a mode variant through every atom in that region, but there's a lighter move: flip one attribute and let the native cascade repaint everything beneath it. That's theming — the exact same mechanism — pointed at a state axis instead of a light/dark one.

How you define it

A conditional group whose modes are states rather than looks. Each mode declares the same role-named tokens:

styles/modes.css.ts
import { defineVariables } from "@salty-css/core/factories";

export const modes = defineVariables({
  conditional: {
    mode: {
      normal:  { surface: "{colors.grey.light}", edge: "#d9d9d9",           label: "{colors.black}" },
      editing: { surface: "#fff8e6",             edge: "{colors.brand.amber}", label: "#7a5c00" },
      danger:  { surface: "#fff0f0",             edge: "{colors.danger}",    label: "{colors.danger}" },
    },
  },
});

Now build the region out of real Salty components that each read the tokens by role and never mention a mode — one shell that carries the state, and different pieces inside it:

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

// the shell — it carries the mode attribute and reads the tokens itself
export const Editor = styled("section", {
  base: {
    padding: "1rem",
    borderRadius: "8px",
    background: "{mode.surface}",
    border: "1px solid {mode.edge}",
  },
});

// two distinct children, both resolving against whatever mode the shell is in
export const EditorToolbar = styled("header", {
  base: {
    display: "flex",
    gap: "0.5rem",
    color: "{mode.label}",
    borderBottom: "1px solid {mode.edge}",
  },
});

export const EditorCanvas = styled("div", {
  base: {
    marginTop: "0.75rem",
    color: "{mode.label}",
  },
});

How you use it

State sets one attribute on the shell; the toolbar and the canvas both repaint, and neither of them changed:

Example
<Editor data-mode={hasUnsavedChanges ? "editing" : "normal"}>
  <EditorToolbar>…</EditorToolbar>
  <EditorCanvas>…</EditorCanvas>
</Editor>

No re-render, no prop drilling, no mode variant on twenty components. And because it's a separate conditional group, it composes with a real color theme on the same tree — data-theme="dark" data-mode="editing" resolves each axis independently, so you never write out the combinations. That orthogonality is the same idea as nested and multi-group themes; the Theming basics page builds it out in full.

There's a bonus to driving this through a real component with a real attribute, and it's a good habit well beyond Salty: because Editor renders an actual element carrying data-mode, your JavaScript can find it — document.querySelector('[data-mode="editing"]') — when your state logic needs to reach a region rather than a single node. That's the "or both" from the attribute-alternative note, cashed in: the tokens do the styling, and the attribute doubles as a stable query handle for the state code that lives outside your components.

Use this when a region changes state as a unit. For a single element's state, Rungs 1–3 are lighter.