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

→ The shared files that turn one component into part of a system.

Advanced setup

Basic setup ends with one component on screen, and everything it needs lives in one file: one styled call, a handful of hard-coded values, no knowledge of the rest of your app. That's the right place to stop when you're checking the install works.

This page is the layer after that — the small set of shared files that turn one component into part of a system. Values it can reference by name. A theme it responds to without being touched. Breakpoints, fonts, templates, document-level styles. None of it is required to ship, all of it can be added the day you need it, and none of it changes what the browser does at runtime, because it all compiles into the same static stylesheet.

Each section is a glimpse rather than a full treatment: what the thing is for, how you define it, how you use it, and where the page that goes deep on it lives.

Where this stuff lives

One decision and one rule come before any of the APIs, and both are about files rather than functions.

Shared styles get their own folder. Component styles stay next to the component; the things every component draws from get one home, usually /styles:

Example
src/
  styles/
    variables.css.ts   design tokens
    themes.css.ts      the switchable layer on top of them
    media.css.ts       named breakpoints
    fonts.css.ts       @font-face and font tokens
    templates.css.ts   reusable style bundles
    global.css.ts      document-level base styles
  components/
    button.css.ts

Nothing enforces this and Salty doesn't care where the files sit — it cares about the suffix. But splitting by what a file defines keeps each one small, and it means you always know which file to open. File structure covers the variations as a project grows.

The compiler finds files by suffix, not by import. There's no barrel file, no registration step, and nothing to re-export. Drop variables.css.ts into /styles and its tokens are on :root on the next build; your components never import it, because tokens are referenced as {path} strings and breakpoints as @name strings. Two things a definition file does need, and they're the same two from Basic setup:

  • The suffix. variables.css.ts compiles; variables.ts type-checks perfectly and emits nothing.
  • A top-level export. The compiler collects exported calls, so an unexported defineVariables(...) produces no CSS — and doesn't warn.

Miss either one and the failure is quiet: tokens print literally as {colors.brand.blue} in the output, and named breakpoints emit as at-rules that silently never match.

The other place these can live is salty.config.ts, which takes the same objects directly:

/salty.config.ts
import { defineConfig } from "@salty-css/core/config";

export const config = defineConfig({
  strict: true,
  variables: {
    colors: { brand: { blue: "#0070f3" } },
  },
});

It's a layout choice rather than a functional one — the two merge, so it's config for what you'd rather keep next to the rest of your setup, files for everything else. Configuration has the full list of what can live in there.

One flag worth setting while you're in that file: strict: true. Salty's failure modes tend to be quiet — a mistyped token path passes through as a literal string rather than stopping the build — and strict mode is how you make them loud. 'warn' is the softer version if you're adding Salty to a codebase that already has plenty of CSS and you'd rather not have the build going red on the first afternoon.

The shared layer

Variables

A token is a value you name once and reference everywhere. Define it with defineVariables and it compiles into a real CSS custom property on :root — so what ships is exactly what you'd have hand-written, plus a TypeScript layer that autocompletes the paths and fails the build on a typo.

Define:

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

export default defineVariables({
  colors: {
    grey: { light: "#f0f0f0" },
    black: "#0a0a0a",
    brand: { blue: "#0070f3" },
  },
  spacing: { small: "8px", medium: "16px", large: "32px" },
});

Use{path.to.token} works anywhere a Salty style takes a value, in styled, className, globals, templates, all the same:

Example
export const Card = styled("div", {
  base: {
    background: "{colors.brand.blue}",
    padding: "{spacing.large}",
  },
});

The habit worth forming now, because it's expensive to undo later: name a token for the role it plays, not the value it holds. spacing.pageMargin survives the day the margin changes; spacing.px120 becomes a small lie you maintain forever.

Variables & tokens — static, responsive, and conditional scopes in full.

Theming

Theming is a switchable layer on top of those tokens: the same names, different values behind them depending on an attribute. Flip the attribute on an ancestor and everything below repaints through the browser's own variable cascade — no provider, no context, no re-render.

Define — token sets under conditional, where each one declares the same names:

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

export const themes = defineVariables({
  conditional: {
    theme: {
      light: { bg: "{colors.grey.light}", text: "{colors.black}", bgAlt: "#e4e4e4" },
      dark:  { bg: "{colors.black}",      text: "{colors.grey.light}", bgAlt: "#1a1a1a" },
    },
  },
});

Use — components read the group's namespace and never know which set is active:

Example
export const Section = styled("section", {
  base: { background: "{theme.bg}", color: "{theme.text}" },
});
Example
<html data-theme="dark">

The rule that makes this work is one layer of indirection: your fixed palette entries are what you define themes from, and the role-named values are what you build against. A component that reads {theme.bg} can become any color scheme without being edited. A component that reads {colors.brand.blue} is pinned to blue forever.

Theming — user toggles, following the OS, nested and multi-axis themes, avoiding the first-paint flash.

Breakpoints

Name a condition once, then reference it by @name the same way you reference a token by {path}.

Define:

/styles/media.css.ts
import { defineMediaQuery } from "@salty-css/react/config";

export const tabletUp = defineMediaQuery((media) => media.minWidth(768));
export const largeMobileDown = defineMediaQuery((media) => media.maxWidth(900));
export const reducedMotion = defineMediaQuery((media) => media.reducedMotion);

Use — as a key inside any style object, with nothing to import at the call site:

Example
export const Grid = styled("div", {
  base: {
    display: "grid",
    gridTemplateColumns: "1fr 1fr",
    gap: "{spacing.medium}",
    "@largeMobileDown": { gridTemplateColumns: "1fr" },
  },
});

Worth noticing the import path: defineMediaQuery comes from the framework package rather than core — @salty-css/react/config here, @salty-css/astro/config on Astro. Most of what's on this page comes from @salty-css/core/* and behaves identically wherever you use it.

Breakpoints & responsive layouts — the full query builder, container queries, and when to swap a value instead of a block of styles.

Fonts

defineFont writes the @font-face rules and hands back something you can drop straight into a style object.

Define — one entry per face:

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

export const inter = defineFont({
  name: "Inter",
  fallback: "system-ui, sans-serif",
  display: "swap",
  variants: [
    { src: "/fonts/Inter-Regular.woff2", weight: 400, style: "normal" },
    { src: "/fonts/Inter-Bold.woff2", weight: 700, style: "normal" },
  ],
});

Use — the returned object stringifies to its font-family, fallback already appended:

Example
export const Heading = styled("h1", {
  base: { fontFamily: inter, fontWeight: 700 },
});

Being straight about the boundary: defineFont writes correct @font-face CSS, and that's the whole job. If you've come from next/font, the subsetting and automatic preloading it did behind the scenes aren't happening here — the loading-performance work stays yours. defineImport is the companion for CSS you only need present: a vendor reset, a syntax-highlight theme, a hosted stylesheet.

Fonts & imports — hosted fonts, exposing a font as a token, and the loading practices that matter.

Templates

A template bundles properties that always travel together, under one name. Think of the font or border shorthands — one declaration, several properties, expanded for you.

This is not the tool for "make a card template." A plain styled component already is your card. Templates are for the smaller, fiddlier job: the group of four properties you'd otherwise retype on every heading and get subtly wrong on the third one.

Define — a shared base per group, with leaves saying only what differs:

/styles/templates.css.ts
import { defineTemplates } from "@salty-css/core/factories";
import { inter } from "./fonts.css";

export default defineTemplates({
  textStyle: {
    headline: {
      base: { fontFamily: inter, fontWeight: "300", lineHeight: "1.2em" },
      regular: { fontSize: "2rem" },
      large: { fontSize: "3.5rem" },
    },
  },
});

Use — the template name becomes a key inside base:

Example
export const Title = styled("h1", { base: { textStyle: "headline.large" } });

Templates resolve in their own cascade layer, below your component styles — so anything you set directly on the component still wins, and you're never fighting a template to override one property of it.

Templates — template variants, function templates, and the text-styles pattern worked all the way out.

Global styles

Salty scopes everything by default. Global styles are the deliberate exception for the handful of rules that belong to the document rather than to a component.

Define:

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

export const globalStyles = defineGlobalStyles({
  body: {
    margin: 0,
    background: "{theme.bg}",
    color: "{theme.text}",
  },
  a: { color: "currentcolor" },
});

There's no use step. No <GlobalStyles /> to render, no class to attach — once the file exists with the right suffix, the rules are simply there.

The line to hold: if a rule is about the document, it's a global; if it's about a component, it isn't. And globals sit in a lower cascade layer than component styles, which means a broad global selector can never out-muscle a styled component no matter how specific it looks. That's the same guarantee that stops global CSS leaking into your components, working in the direction people don't expect.

Global styles — the layer order, the built-in reset, and what belongs here versus a dedicated factory.

Back in the component

The rest of the list isn't setup — there's nothing to wire, no file to register. It's what the layer above unlocks, and it's here so you know the tools exist before you go hand-roll them.

Variants

A variant is a named branch of styles that becomes a typed prop. This is where most of the day-to-day work happens once tokens exist.

Define:

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

export const Button = styled("button", {
  base: {
    padding: "{spacing.small} {spacing.medium}",
    borderRadius: "6px",
    cursor: "pointer",
  },
  variants: {
    tone: {
      solid: { background: "{theme.text}", color: "{theme.bg}" },
      ghost: { background: "transparent", color: "{theme.text}" },
    },
    size: {
      small: { fontSize: "0.875rem" },
      large: { fontSize: "1.125rem", padding: "{spacing.medium} {spacing.large}" },
    },
  },
  defaultVariants: { tone: "solid", size: "small" },
});

Use:

Example
<Button>Save</Button>                        {/* solid, small */}
<Button tone="ghost" size="large">Cancel</Button>

Two things happen here that are easy to miss. defaultVariants means the bare <Button> is already a real variant, not an unstyled fallback. And variant props are consumed by Salty rather than forwarded — tone selects CSS and is then dropped, so it never lands on the DOM as a stray attribute. When you do want them forwarded, that's passProps.

Styled API — compound and boolean variants, extending components, swapping elements, passProps.

Interactive state and scoping

Every styled component gets a hashed class, and & is that class. So the nesting you already know from SCSS works, scoped, without leaking.

The mindset that keeps components small: climb only as far as the state forces you to. The browser is already tracking hover, focus, [open], validity, and what an element contains — style that directly and you've written no JavaScript at all.

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

export const Button = styled("button", {
  base: {
    transition: "background 150ms ease",

    // states the browser already tracks
    "&:disabled": { opacity: 0.5, cursor: "not-allowed" },

    // reach into a child, or into another component by identity
    [`& ${Icon}`]: { opacity: 0.7, transition: "opacity 150ms ease" },
    "&:hover": {
      background: "{theme.bgAlt}",
      [`& ${Icon}`]: { opacity: 1 },
    },
  },
});

Only when the state genuinely isn't visible to the browser — "submitting", "error" — does it become a variant you drive from React. Reaching for useState first is the common wrong turn, and it's how components get big.

Interactive state and Scoping & composition.

Animations

Transitions need no API at all — a transition line on the resting style and the browser tweens the difference. For genuinely multi-step motion, keyframes is the one primitive Salty adds.

Define:

/styles/animations.css.ts
import { keyframes } from "@salty-css/react/keyframes";

export const fadeIn = keyframes({
  animationName: "fadeIn",
  params: { duration: "500ms", easing: "ease-in-out", fillMode: "forwards" },
  from: { opacity: 0 },
  to: { opacity: 1 },
});

Use — drop it into animation, with or without overriding the defaults:

Example
export const Panel = styled("div", { base: { animation: fadeIn } });
export const Toast = styled("div", {
  base: { animation: fadeIn({ duration: "200ms", easing: "ease-out" }) },
});

Animations — the full keyframes options, and the state-driven motion pattern that needs neither keyframes nor React state.

Helpers

A helper is a plain function that returns a value, called once while your styles compile. Salty ships two, and neither is required to use the library — reach for one when it solves a problem you actually have.

/styles/helpers.css.ts
import { defineViewportClamp } from "@salty-css/core/helpers";

export const fhdClamp = defineViewportClamp({
  screenSize: 1920,
  minMultiplier: 0.5,
  maxMultiplier: 1.25,
});
Example
import { color } from "@salty-css/core/helpers";

export const Hero = styled("h1", {
  base: {
    fontSize: fhdClamp(96),                              // fluid, no breakpoints
    color: color("{colors.brand.blue}").darken(0.1),     // resolved at build time
  },
});

There's no registration step and no compiler hook — which also means writing your own is nothing more than exporting a function. A rem(24) helper that does the division you'd otherwise do in your head is a perfectly good use of the idea.

One boundary that catches people: color() can only transform values it can see at build time. Hand it a themed value and it passes through unchanged, because that value doesn't exist yet when the compiler runs. Derive shades from your fixed palette and store the result as a themed token.

Helpers, Viewport clamp, Color function.

What's deliberately not here

Three things sit one step past this page, and it's worth knowing they exist so you don't hand-roll them:

  • Modifiers — value transformers registered on defineConfig. When you find yourself converting the same kind of value at every call site, this is the hook.
  • Runtime styles — request-time scoped CSS, for the case where the values genuinely aren't knowable at build time (a palette from a CMS, for instance). It's the deliberate escape hatch from the build-time model, and it's worth reading the tradeoffs before reaching for it.
  • Monorepos and incremental adoption — tokens are plain TypeScript exports, so they travel across packages; and Salty compiles alongside whatever CSS you already have rather than replacing it.

Stuck on something troubleshooting doesn't cover? The Discord is the fastest way to get untangled.