# Salty CSS
> Build-time CSS-in-TS for React, Next.js and Astro. Compiles `styled(...)` calls
> in `*.css.ts` files to plain CSS at build time — zero runtime cost, with full
> TypeScript autocomplete on design tokens, themes, variants and media queries.
> Works with React Server Components.
This file contains the full rendered documentation for Salty CSS, concatenated
for LLM ingestion. Three framework variants (React, Next.js, Astro) are included
in order — they are translations of the same docs with framework-specific install
commands, imports, and code snippets.
Source: https://salty-css.dev/llms-full.txt — Generated: 2026-06-23
Companion index: https://salty-css.dev/llms.txt
---
# Documentation — React
Source: https://salty-css.dev/docs/react/
Salty CSS is a build-time CSS-in-**TS** library for React, Next.js and Astro. You author styles in `.css.ts` files, the compiler turns them into real CSS, and your runtime ships with no styling engine attached. Meow.
It's a good fit when you want **typed styles, real design tokens, and theming that doesn't need a React provider** — without giving up on the developer experience of writing CSS next to your component.
### What you get
- **Typed styles** — `styled("button", { ... })` returns a typed component; variants become typed props.
- **Design tokens** — `defineVariables` for static, **responsive**, and **conditional** tokens (the conditional ones are how theming works).
- **Theming without a provider** — flip a `data-theme` attribute on `` and every consumer of `{theme.color}` updates.
- **Templates** — `defineTemplates` for reusable style bundles with their own variants.
- **Media queries that read like English** — `media.minWidth(720).and.dark`.
- **Fluid sizing** — `defineViewportClamp` for `clamp()`-based values that scale with the viewport.
- **Type-safe class names** — `className({ ... })` when you want the styles without the component wrapper.
- **A CLI** — `npx salty-css init` / `generate` / `build` / `up` for the boring bits.
### TL;DR — install it
Inside any React, Next.js or Astro project:
```bash
npx salty-css init
```
Then create a component in a `*.css.ts` file:
```ts
// /components/my-button.css.ts
import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: {
padding: "0.75rem 1.25rem",
borderRadius: "6px",
background: "{theme.color}",
color: "{theme.background}",
},
});
```
…and use it like any other .
### Where to go next
- **New here?** → [Quick Start](/docs/quick-start/) walks you from `init` to a themed component with variants in about 15 minutes.
- **Adding it to a project?** → [Installation](/docs/installation/) covers the per-framework wiring.
- **Need the reference?** → [`styled`](/docs/api/styled/), [`className`](/docs/api/classname/), [`defineConfig`](/docs/api/config/).
### Search the docs
Hit `Ctrl + K` (or `⌘ + K` on macOS) anywhere in the docs to open the search modal — or click **Search** at the top of the sidebar. Matching is fuzzy: partial terms like `variant`, `media` or `clamp` are usually enough.
### What's in the docs
#### Getting Started
- [Quick Start](/docs/quick-start/) — install, your first component, variants, theming, fonts.
- [Installation](/docs/installation/) — per-framework setup (Next.js, Vite, Webpack, Astro).
- [Usage](/docs/usage/) — file suffixes, dev-time naming, DevTools.
- [Troubleshooting](/docs/troubleshooting/) — the fix list for the most common issues.
- [CLI](/docs/cli/) — use Salty CSS from the command line.
- [FAQ](/docs/faq/) — short answers to the things people ask first.
#### Styling
- [Component styles](/docs/basics/) — `styled`, `className`, and where each one fits.
- [Variables](/docs/variables/) — design tokens with `defineVariables` (static, responsive, conditional).
- [Theming](/docs/theming/) — dark mode and multi-theme without a provider.
- [Fonts](/docs/fonts/) — `defineFont` for local files and remote stylesheets.
- [Imports](/docs/imports/) — pull in external CSS with `defineImport`.
- [Class styles](/docs/classnames/) — `className` for plain class-based styles.
- [Variants](/docs/variants/) — prop-driven variants, compound, and `anyOfVariants`.
- [Overrides](/docs/overrides/) — extend components, swap elements, forward props with `passProps`.
- [Media queries](/docs/media-queries/) — media queries, container queries, breakpoints.
- [Animations](/docs/animations/) — keyframes, stagger, pause/resume.
- [Templates](/docs/templates/) — reusable styles with `defineTemplates`.
#### Utilities
- [Viewport clamp](/docs/viewport-clamp/) — fluid responsive sizing.
- [Color function](/docs/color-function/) — manipulate colors at build time.
- [Modifiers](/docs/modifiers/) — custom value transformers.
#### API reference
- [`styled` function](/docs/api/styled/) — full API for the `styled` component factory.
- [`className` function](/docs/api/classname/) — full API for class-based styles.
- [`defineConfig`](/docs/api/config/) — every option that lives in `salty.config.ts`.
- [`define*` factories index](/docs/api/define-factories/) — one-page index of every factory.
### Get support
Join the [Salty CSS Discord server](https://discord.gg/R6kr4KxMhP) for help, or check the [GitHub repository](https://github.com/margarita-form/salty-css) for source and issues.
---
# Getting Started — React
Source: https://salty-css.dev/docs/react/getting-started/
Everything you need to get a Salty CSS project off the ground. Start with the **Quick Start** for a fifteen-minute end-to-end walkthrough, or jump into **Installation** if you'd rather wire the build plugin into an existing app. The remaining pages cover day-to-day **Usage**, the **CLI**, **ESLint** support, common **Troubleshooting**, and **FAQ** answers.
---
# Quick Start — React
Source: https://salty-css.dev/docs/react/quick-start/
Goal: by the end of this page you have Salty CSS installed, a typed component on screen, prop-driven variants, dark mode that flips without a provider, and a custom font registered. Budget about 15 minutes.
### 1. Install
Inside any React, Next.js, Vite, or Astro project, run:
```bash
npx salty-css init
```
The CLI detects your framework, adds the right plugin (`@salty-css/next`, `@salty-css/vite`, `@salty-css/webpack`, or `@salty-css/astro`), drops a `salty.config.ts` in the project root, and wires up the build. If you'd rather wire it up yourself, see [Installation](/docs/installation/).
### 2. Your first component
All Salty definitions live in files matching `*.css.ts`, `*.css.tsx`, `*.salty.ts`, `*.styled.ts`, or `*.styles.ts` — the suffix is how the compiler picks them up at build time.
```ts
// /components/card.css.ts
import { styled } from "@salty-css/react/styled";
export const Card = styled("section", {
base: {
padding: "1.5rem",
borderRadius: "12px",
background: "{theme.background}",
color: "{theme.color}",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
},
});
```
Use it like any other :
```tsx
import { Component } from "./my-component.css";
const MyPage = () => {
return (
This is a Salty CSS component
);
};
export default MyPage;
```
Note the `{theme.background}` / `{theme.color}` tokens — we'll define those in step 4.
### 3. Add variants
Variants turn props into typed style branches. Add a `size` axis and a `tone` axis:
```ts
// /components/card.css.ts
import { styled } from "@salty-css/react/styled";
export const Card = styled("section", {
defaultVariants: { size: "medium", tone: "neutral" },
base: {
padding: "1.5rem",
borderRadius: "12px",
background: "{theme.background}",
color: "{theme.color}",
},
variants: {
size: {
small: { padding: "1rem", borderRadius: "8px" },
medium: { padding: "1.5rem" },
large: { padding: "2.5rem", borderRadius: "16px" },
},
tone: {
neutral: {},
brand: { background: "{theme.highlight}", color: "{theme.background}" },
muted: { background: "{theme.altBackground}" },
},
},
compoundVariants: [
{ size: "large", tone: "brand", css: { fontWeight: 600 } },
],
});
```
`size` and `tone` are now typed props. Render with:
```tsx
import { Button } from "./button/button.css";
export const MyComponent = () => {
return (
);
};
```
For more on variants — including `anyOfVariants` for "any of these branches matches" rules — see [Variants](/docs/variants/).
### 4. Add design tokens and dark mode
Salty themes are CSS variables under the hood. You declare two (or more) value sets under `conditional.theme`, and the active set is picked by an attribute on an ancestor — usually ``. No provider, no context.
```ts
// /styles/themes.css.ts
import { defineVariables } from "@salty-css/core/factories";
export const palette = defineVariables({
colors: {
ink: "#0d1117",
paper: "#ffffff",
sand: "#f5f1e8",
coral: "#ff6b5a",
},
});
export const themes = defineVariables({
conditional: {
theme: {
light: {
background: "{colors.paper}",
altBackground: "{colors.sand}",
color: "{colors.ink}",
highlight: "{colors.coral}",
},
dark: {
background: "{colors.ink}",
altBackground: "#1c2128",
color: "{colors.paper}",
highlight: "{colors.coral}",
},
},
},
});
```
Activate a theme by setting the attribute on any ancestor — usually the root:
```html
...
```
That's the whole switch. Every `{theme.background}` / `{theme.color}` reference in your styles now reads the dark values. Flip the attribute back to `light` and it all updates again. For a working toggle that persists the choice in `localStorage` and avoids the first-paint flash:
```tsx
// /components/theme-toggle.tsx
"use client";
import { useEffect, useState } from "react";
type Theme = "dark" | "light";
const STORAGE_KEY = "theme";
const readInitial = (): Theme => {
if (typeof window === "undefined") return "light";
const saved = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
if (saved === "dark" || saved === "light") return saved;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
export const ThemeToggle = () => {
const [theme, setTheme] = useState("light");
useEffect(() => {
const initial = readInitial();
setTheme(initial);
document.documentElement.dataset.theme = initial;
}, []);
const toggle = () => {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
document.documentElement.dataset.theme = next;
window.localStorage.setItem(STORAGE_KEY, next);
};
return (
);
};
```
To avoid a flash of the wrong theme on first paint in Next.js, inline a tiny pre-hydration script in `app/layout.tsx` (or `_document.tsx` for the Pages Router) so the attribute is set before React hydrates:
```tsx
// /app/layout.tsx
const setThemeScript = `
try {
var t = localStorage.getItem("theme");
if (t !== "dark" && t !== "light") {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.dataset.theme = t;
} catch (e) {}
`;
export default function RootLayout({ children }) {
return (
```
The `is:inline` directive ensures the script runs before the first paint, so users never see a flash of the wrong theme.
See [Theming](/docs/theming/) for the deeper guide (system preference, multiple independent groups, deriving shades with `color()`).
### 5. Register a custom font
`defineFont` registers a font and gives you back something you can use as a `font-family` value, a CSS variable, a class name, or a `style` spread.
```ts
// /styles/fonts.css.ts
import { defineFont } from "@salty-css/core/factories";
export const display = defineFont({
name: "Mona Sans",
fallback: "system-ui, -apple-system, sans-serif",
variants: [
{
src: "/fonts/Mona-Sans.woff2",
weight: "200 900",
style: "normal",
display: "swap",
},
],
});
```
Use it in a styled component:
```ts
import { styled } from "@salty-css/astro/styled";
import { display } from "../styles/fonts.css";
export const Title = styled("h1", {
base: {
fontFamily: display,
fontSize: "clamp(2rem, 4vw, 3.5rem)",
color: "{theme.color}",
},
});
```
`display` stringifies to its `font-family` value, so it works inline. You can also read `display.variable` (the CSS custom property), `display.className`, or `display.style` (an object you can spread onto a `style` prop). See [Fonts](/docs/fonts/) for remote fonts (`import`) and per-variant overrides.
### What you have now
A typed component with variants, a theme that flips on a single attribute, and a custom font registered through `defineFont`. That's the everyday Salty CSS surface.
### Where to go next
- **Variants in depth** → [Variants](/docs/variants/) (compound, `anyOfVariants`, defaults).
- **Reusable style bundles** → [Templates](/docs/templates/).
- **Fluid type / spacing** → [Viewport clamp](/docs/viewport-clamp/).
- **Media queries and breakpoints** → [Media queries](/docs/media-queries/).
- **Full reference** → [`styled`](/docs/api/styled/), [`className`](/docs/api/classname/), [`defineConfig`](/docs/api/config/).
- **Per-request styles (CMS, tenant overrides)** → [`defineRuntime`](/docs/api/runtime/).
### Use the CLI
- Scaffold a component: `npx salty-css generate [filePath]`
- Force a CSS build: `npx salty-css build [directory]`
- Bump packages: `npx salty-css up`
### Good to know
1. All Salty CSS definitions (`styled`, `className`, `keyframes`, `defineFont`, etc.) must live in `*.css.ts` / `*.css.tsx` (or `.salty.ts`, `.styled.ts`, `.styles.ts`). The suffix is the contract — that's how the compiler knows to evaluate the file.
2. `styled` can extend non-Salty components (`styled(ThirdPartyLink, { ... })`), but the wrapped component must accept a `className` prop. See [Overrides](/docs/overrides/).
3. CSS-in-JS values can be `string`, `number`, **function**, or **promise** — but importing heavy runtime libraries into a `*.css.ts` file will slow your build (and may crash if the library assumes a browser). Keep these files style-focused.
### If something didn't work
→ [Troubleshooting](/docs/troubleshooting/). The most common cause (a wrong filename suffix) is the very first entry.
### Get support
Stuck? Drop into the [Salty CSS Discord](https://discord.gg/R6kr4KxMhP) and someone will untangle it with you.
---
# Installation — Astro
Source: https://salty-css.dev/docs/astro/installation/
The fastest way to start in any framework:
```bash
npx salty-css init
```
The init command detects your framework, installs the right packages, creates `salty.config.ts`, and wires the build plugin into your existing bundler config. For most projects that's all you need.
For a manual install on **Astro**, run:
```bash
npm i @salty-css/astro
```
### Astro
1. In your existing Astro repository you can run `npx salty-css init` to automatically configure Salty CSS.
2. Create your first Salty CSS component with `npx salty-css generate [filePath]` (e.g. `src/custom-wrapper`).
3. Import your component in any `.astro` page or layout and see it working!
#### Manual configuration
1. Install the Astro integration:
```bash
npm i @salty-css/astro
```
2. Create `salty.config.ts` in your project root.
3. Add the Salty CSS integration to `astro.config.mjs`:
```ts
import { defineConfig } from "astro/config";
import saltyIntegration from "@salty-css/astro/integration";
export default defineConfig({
integrations: [saltyIntegration()],
});
```
`saltyIntegration` accepts `{ srcDir?: string; rootDir?: string }`. `srcDir` defaults to `'src'`; `rootDir` defaults to the Astro config root.
4. Make sure that `salty.config.ts` and `astro.config.mjs` are in the same folder.
5. Build the `saltygen` directory by running your app once or via the CLI: `npx salty-css build [directory]`.
6. Import global styles from `saltygen/index.css` in your global stylesheet: `@import 'insert_path_to_index_css';`.
### Manual setup
If `salty-css init` picks the wrong framework, can't find your bundler config, or you'd rather wire things up by hand, follow the manual setup for your stack. The framework-specific snippet above includes the precise commands and config edits; the steps below are the universal shape.
1. **Install the packages.** Each framework needs its runtime plus the core compiler:
- Next.js: `npm i @salty-css/next @salty-css/core @salty-css/react`
- React + Vite: `npm i @salty-css/vite @salty-css/core @salty-css/react`
- React + Webpack: `npm i @salty-css/webpack @salty-css/core @salty-css/react`
- Astro: `npm i @salty-css/astro @salty-css/core`
2. **Wire the bundler plugin.** `withSaltyCss(nextConfig)` for Next.js, `saltyPlugin(__dirname)` for Vite, `saltyPlugin(config, __dirname)` in `webpack.config.js` for Webpack, the integration for Astro.
3. **Create `salty.config.ts`** in the same directory as your bundler config (e.g. next to `next.config.ts` or `vite.config.ts`):
```ts
import { defineConfig } from "@salty-css/astro/config";
export const config = defineConfig({
// Add variables, templates, modifiers as you grow.
});
```
4. **Import the generated stylesheet.** With the default `importStrategy: 'root'`, Salty CSS expects one stylesheet to be imported at your app root. Most framework plugins do this for you on first run; if not, add `@import "../saltygen/index.css";` (or the appropriate path) to your global CSS.
5. **Build once.** Run your dev server (or `npx salty-css build`) so `saltygen/` exists before the first render.
### Peer dependencies
You'll need:
| Package | Version | Notes |
| ------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
| `node` | 18 or newer | Required for the build pipeline (esbuild + ESM). |
| `typescript` | 5.x | Needed because `.css.ts` files are evaluated through TypeScript. |
| `react` | 18 or 19 (when using React/Next) | The `@salty-css/react` runtime targets modern React. |
| `next` | 13 (App Router) or newer; tested through 16.2 | For `@salty-css/next`. Webpack and Turbopack both work via `withSaltyCss`. |
| `vite` | 5 or newer | For `@salty-css/vite`. |
| `astro` | 4 or newer | For `@salty-css/astro`. |
The exact ranges live in each package's `peerDependencies` — check `package.json` if you're on a fringe version.
### Verify your install
After running the dev server (or `npx salty-css build`), confirm:
1. **`saltygen/` exists** at the root of the package you initialised. It should contain at least `index.css` and a `salty.config.js` snapshot.
2. **`saltygen/index.css` is non-empty.** A few `@layer` declarations and your reset should be there even before you write any components.
3. **A test component renders styled.** Create a `*.css.ts` file with a tiny styled component, use it on a page, and inspect the element in DevTools — you should see a class like `s_xxxx` and a matching rule in the Styles panel.
4. **No build warnings about missing plugin.** Salty CSS logs a warning at build time if the plugin didn't load — search your terminal output for `salty-css`.
If any step fails, jump to [Troubleshooting](/docs/troubleshooting/).
The compiler can't warn you about two common structural mistakes: unexported `styled` calls, and `variants` accidentally nested inside `base` instead of alongside it. The [ESLint plugin](/docs/eslint/) catches both — worth setting up once.
---
# Usage — Astro
Source: https://salty-css.dev/docs/astro/usage/
### Create components
Salty CSS only picks up files whose names end with one of these suffixes:
| Suffix | When to use it |
| ------------ | ------------------------------------------------------------------ |
| `.css.ts` | Default for any style or component file. Works everywhere. |
| `.css.tsx` | Same as `.css.ts`, but JSX is allowed in the file. |
| `.salty.ts` | Alias of `.css.ts` — pick whichever reads better in your project. |
| `.styled.ts` | Alias of `.css.ts`, conventionally used for `styled` factories. |
| `.styles.ts` | Alias of `.css.ts`, conventionally used for `defineTemplates` etc. |
A `.ts` file with the same content but missing the right suffix will type-check fine but produce **no CSS** at build time — this is the single most common "my styles aren't appearing" cause. See [Troubleshooting](/docs/troubleshooting/) if you hit it.
### Component structure
```ts
// /components/my-component.css.ts
import { styled } from "@salty-css/astro/styled";
export const Component = styled("div", {
className: "wrapper", // Optional custom class name
element: "section", // Optional override for the rendered HTML element
base: {
// Base styles that are always applied
display: "flex",
padding: "1rem",
backgroundColor: "#f5f5f5",
},
variants: {
// Conditional styles based on props
size: {
small: { padding: "0.5rem" },
large: { padding: "2rem" },
},
color: {
primary: { backgroundColor: "blue", color: "white" },
secondary: { backgroundColor: "gray", color: "black" },
},
},
compoundVariants: [
// Styles applied when multiple variant conditions are met
{
size: "small",
color: "primary",
css: { borderRadius: "4px" },
},
],
});
```
### Using components
```astro
---
// src/pages/index.astro
import { Component } from "../components/my-component.css";
---
This is a Salty CSS component
```
### Naming components in DevTools
In development builds, every styled component renders with a `data-component-name` attribute matching its export name. Search for `[data-component-name="Button"]` in the elements panel to jump straight to it. You can override the label with the `displayName` option on the styled definition:
```ts
export const PrimaryButton = styled("button", {
displayName: "PrimaryButton",
base: {
/* … */
},
});
```
In production builds the attribute is stripped, so it's a debugging aid only.
### Where to go next
- **Add prop-driven styles** → [Variants](/docs/variants/) (and [`anyOfVariants`](/docs/variants/#anyof-variants---or-logic) for OR-logic).
- **Share style bundles across components** → [Templates](/docs/templates/).
- **Add design tokens** → [Variables](/docs/variables/).
- **Add dark mode** → [Theming](/docs/theming/).
- **Extend a third-party component** → [Overrides](/docs/overrides/).
- **API reference** → [`styled`](/docs/api/styled/) · [`className`](/docs/api/classname/) · [`defineConfig`](/docs/api/config/).
### Demo Projects
- **Next.js Demo Project**: [View on GitHub](https://github.com/margarita-form/salty-css-website)
- **React + Vite Demo**: [View on GitHub](https://github.com/margarita-form/salty-css-react-vite-demo)
- **CodeSandbox Demo**: [](https://codesandbox.io/p/github/margarita-form/salty-css-react-vite-demo/main?import=true&embed=1)
---
# Troubleshooting — Astro
Source: https://salty-css.dev/docs/astro/troubleshooting/
A short, opinionated checklist for the issues that come up most often. If your symptom isn't here, the [Discord server](https://discord.gg/R6kr4KxMhP) is the fastest way to get unstuck.
### My styles aren't appearing
Work through these in order — the first one is the cause about 80% of the time.
1. **Wrong filename suffix.** Salty CSS only picks up `*.css.ts`, `*.css.tsx`, `*.salty.ts`, `*.styled.ts`, or `*.styles.ts`. A file named `my-component.ts` will type-check fine but produce no CSS. Rename it.
2. **`saltygen/index.css` is stale or missing.** Restart the dev server. If you're not using a dev server, run `npx salty-css build` to regenerate it.
3. **Plugin not wired up.** Confirm `withSaltyCss` (Next.js), `saltyPlugin` (Vite), or the Astro integration appears in your bundler config. Without the plugin, the compiler never runs.
4. **Importing `saltygen/index.css` is missed.** With `importStrategy: 'root'` (the default), the generated stylesheet must be imported once at the root of your app. Frameworks like Next.js do this for you; check the install snippet for your stack if you set things up manually.
5. **The component isn't actually used.** Salty CSS tree-shakes unused exports — if no module imports the styled component, no CSS is emitted for it. Make sure the export reaches a rendered tree.
### I can see the class but the rule isn't applying
- **Specificity conflict.** A higher-specificity rule from another stylesheet may be winning. Inspect the element in DevTools and look at the cascade. Bump the Salty rule's `priority` (see [`styled` API](/docs/api/styled/)) or scope your override more tightly.
- **Order of cascade layers.** Salty CSS uses `@layer` internally. Anything you import with [`defineImport`](/docs/imports/) lives in the earliest layer, so it always loses to your own styles — and vice versa, your imports won't override Salty rules.
- **`anyOfVariants` losing to a regular variant.** `anyOfVariants` uses `:where()` and has zero specificity by design. If you need it to win, move the rule into a regular variant or a `compoundVariants` entry.
### A token reference shows up literally as `{colors.brand}` in the browser
That's the parser refusing to resolve an unknown path:
- Typo? Check `defineVariables({ colors: { ... } })` matches the path exactly.
- Did you forget to register the variables file? With `defineVariables` in a `*.css.ts` file, the file just needs to be imported somewhere in your build graph (typically you re-export it from a styles barrel).
- Variables defined inside [`defineConfig`](/docs/api/config/) are picked up automatically; the file imports apply only to standalone `defineVariables` calls.
Turn on `strict: true` (or `'warn'`) in `defineConfig` to surface these as build errors instead of silently passing them through.
### Build error: "Unknown frontmatter key" (contributors only)
This applies only when editing the salty-css.dev documentation site itself, not when using Salty CSS in your own project. The website docs parser validates frontmatter against a fixed allowlist — if you added a custom key to a doc page, either remove it or extend [`docs-content.ts`](https://github.com/margarita-form/salty-css-website/blob/main/src/lib/docs-content.ts) to accept it.
### `npx salty-css init` picked the wrong framework
Re-run with the framework set explicitly via the package manager — `init` looks at the dependencies it can see. If you're in a monorepo, run it from the package's own root, not the workspace root.
If it already wrote the wrong config, deleting `salty.config.ts` and re-running is the cleanest fix.
### React Server Components / SSR
- Salty CSS works in **server components** — styles are emitted at build time, so there's no runtime dependency to ship to the server. The `styled` function returns a regular React component you can render anywhere.
- Where you do need `"use client"`: any component that uses runtime React features (state, effects, event handlers) — including a theme toggle — must be a client component, but the styled component it uses can stay in server land.
- Hydration mismatches around theming are usually a flash-of-wrong-theme issue: the server renders one theme attribute, the client toggle script applies another. See the [Theming](/docs/theming/) page for the inline pre-hydration script pattern.
### Finding a generated class in DevTools
Every styled component emits a `data-component-name` attribute in development. Open the element inspector, find the attribute (e.g. `data-component-name="Button"`), and the class name on that element is the Salty-generated hash. Searching the Styles panel for that hash takes you straight to the rule.
### Importing heavy modules in a `.css.ts` file
Salty CSS evaluates `.css.ts` files at build time, so importing big runtime libraries (or anything that touches `window`) can slow down compilation or fail outright. Keep `.css.ts` files focused on style definitions; if you need a value from a heavy module, derive it once and re-export it from a lightweight intermediate file.
For modules you genuinely need at build time but want excluded from the Salty bundle, add them to `externalModules` in [`defineConfig`](/docs/api/config/#externalmodules).
### Still stuck?
- [Discord](https://discord.gg/R6kr4KxMhP) — community + maintainers.
- [GitHub issues](https://github.com/margarita-form/salty-css/issues) — for confirmed bugs and feature requests.
---
# Frequently Asked Questions — Astro
Source: https://salty-css.dev/docs/astro/faq/
### About Salty CSS
#### What is Salty CSS?
Salty CSS is a TypeScript-first, build-time CSS-in-TS library for React, Next.js and Astro. You author styles in `.css.ts` files using a typed `styled()` API, and the compiler turns them into real CSS at build time. The runtime ships with no styling engine — your components just render with the generated class names.
#### Is Salty CSS zero-runtime?
The styling layer is. Styles, variants, templates, and tokens are resolved by the build, so the only thing that ships at runtime is a small helper that maps your variant props to class names. There is no runtime style serializer, no in-DOM `` + an element with `className={className}` without further wiring. |
| `getDynamicStylesCss` | alias of `css` | Deprecated. Kept exported for backward compatibility with the standalone helper that predates `defineRuntime`. Prefer `css`. |
#### The `config` argument
`config` is `Partial` — the same shape `defineConfig` accepts. The fields the runtime actually uses are `variables`, `templates`, `mediaQueries`, and `modifiers`. The practical recipe is to import your project's existing config and pass it in, so CMS-supplied `{colors.brand}` tokens resolve to the same CSS variables your `styled` components already use:
```ts
import { defineRuntime } from "@salty-css/astro/runtime";
import config from "../../salty.config";
const runtime = defineRuntime(config);
```
`defineRuntime()` (with no argument) is fine when the incoming styles don't reference any tokens or templates.
### Feature support
`defineRuntime` runs the same parser as the build-time compiler, so most Salty features come along for free. The exception is anything that requires a component wrapper — `defineRuntime` returns CSS, not a component.
| Feature | Supported | Notes |
| --------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Token substitution (`{colors.brand}`) | yes | Pass the matching `variables` in `config`. |
| Media queries (`@media (...)`) | yes | Scoped under the resolved class — `.${className} { ... }` inside the `@media` block. |
| Modifiers / pseudo-classes (`&:hover`, `&[data-state]`) | yes | Ampersand-expansion is identical to the build-time parser. |
| Nested selectors (`'& > svg'`) | yes | Combinators are appended to the scope class. |
| Templates (`textStyle: 'caption'`) | yes | Pass the matching `templates` in `config`. |
| `variants` / `compoundVariants` / `anyOfVariants` | partial | The parser emits the variant selectors (`.${className}.size-sm`, etc.), but there is **no prop → class mapping** here. Either pass a flat object, or select the active branch yourself before calling `resolve`. |
| `defaultVariants`, `defaultProps`, `passProps`, `element`, `as` | no | These are `styled()` component concepts. `defineRuntime` returns strings. |
| `keyframes`, `defineGlobalStyles`, `defineFont` | no | Build-time only — they need to live in the generated stylesheet, not a per-request `
{block.heading}
{block.body}
);
}
```
The class is a hash of `block.css`, so two blocks with identical styles produce the same class — if you want to ship one rule per unique shape, collect the pairs across the request and dedupe by `className` before rendering the `
{children}
);
}
```
`{colors.text.onBrand}` resolves against your project config; the `&:hover` and `@media` blocks compose exactly the way they do inside a `styled()` definition.
### Scoping
`resolve(styles)` defaults the scope to `.${className}`, which is what you want most of the time — render the returned CSS in a ` next to an element with className={className}
```
Because the same parser runs, JSON-shaped input gets the full Salty surface for free: `{colors.brand}` tokens resolve to the same CSS variables your `styled` components use; `@media`, `&:hover`, and nested selectors all keep working; and two blocks with structurally-identical payloads collapse to a single rule via the shared hash. A CMS block effectively ships "CSS in JSON" and slots straight into your design system — no second pipeline, no escape-hatch global CSS file, and no client styling runtime appearing on the page just because some rules were resolved per-request. The CSS is emitted as a string into the HTML response, and the browser parses it the way it parses your static stylesheet.
Reach for this when content authors want power — give them the parser, not a constrained subset. See the [`defineRuntime` API reference](/docs/api/runtime/) for the full surface: signature, feature-support table, scoping, and worked examples.
#### Smart Hashing and Deduplication
The compiler uses deterministic hashing (`toHash`). If a `HeroBlock` and a `TextBlock` independently define identical style objects, they generate the exact same hash. The CSS rules are emitted to the final stylesheet only once, automatically deduplicating your output and keeping the CSS payload minimal.
### Execution over Extraction (esbuild vs. AST)
To extract styles at build time, a compiler has to understand your code. There are two broad families for doing this, and they make different tradeoffs.
The first is **Abstract Syntax Tree (AST) static analysis**, used to great effect by tools like Panda CSS (and, historically, by Babel-based extractors like Linaria). These are genuinely impressive pieces of engineering — modern static extractors do identifier resolution, constant folding, and partial evaluation, and they're fast and battle-tested in large codebases.
The tradeoff is inherent to the approach: the analyzer reasons about your code without running it. When a value can only be known at runtime — a prop driven by state, a token resolved through an indirection the analyzer can't follow, a style object built by a sufficiently dynamic helper — the extractor has to either skip it or pre-generate every plausible combination ahead of time. That's why static tools offer escape hatches (Panda's `staticCss`, for instance) to declare the dynamic surface up front. It's a perfectly reasonable model; it just asks you to keep your styles statically analyzable, and to reach for those hatches when you can't.
Salty CSS sits in the other family: **it executes rather than analyzes.** Instead of parsing an AST, the Salty compiler uses `esbuild` to actually evaluate your `.css.ts` files in a Node environment during the build step. This is the same fundamental idea Vanilla Extract pioneered with its `.css.ts` model, and Salty owes it a clear debt.
Because the code is genuinely executed:
- You can safely import variables, functions, or design tokens from anywhere in your codebase.
- You can write complex helper functions to generate style objects.
- You can even use `async/await` inside your style definitions (e.g., fetching a remote design token registry during the build).
If your code runs in Node, Salty CSS can compile it. The cost of this approach is the constraint it places on _where_ styles live — which is the subject of the next section — but in exchange you don't have to keep your styling logic statically legible to a parser.
### The Compilation Pipeline
Knowing _why_ Salty CSS executes code rather than parsing ASTs is useful context. Knowing _what actually happens_ between saving a file and seeing a styled component in the browser is what you need when something breaks — or when you're trying to understand why a particular file is slowing your build down.
There are two distinct journeys to trace. The first is the **build-time funnel**: what runs when you start the dev server or kick off a production build. The second is the **load-time funnel**: what the bundler does with a `.css.ts` file when it encounters it as an import, and what the browser ends up with.
#### Funnel 1 — Build time: `.css.ts` → `saltygen/index.css`
```
Plugin hook → Discovery → Config pass → esbuild (per file) → import() → CSS generation → layer assembly → index.css
```
**1. Plugin hook fires**
The bundler plugin (`saltyPlugin` for Vite, the webpack loader, `withSaltyCss` for Next.js) hooks into the bundler's `buildStart`. It kicks off a full generation, which begins by wiping `saltygen/` clean and recreating its working subdirectories from scratch. Everything flows from this point on a fresh slate.
**2. File discovery**
A recursive walk of the project tree skips `node_modules` and `saltygen/` entirely. A file counts as salty if its name matches `*.(salty|css|styles|styled).(ts|tsx|js|…)`. Within that set, any file that also calls a `defineX(` factory is additionally flagged as a _config file_ and queued for processing first. Two sets leave this step: all salty files, and the config-file subset.
> **Perf:** This is a synchronous depth-first filesystem walk across the whole project. In a large monorepo with thousands of directories, the syscall overhead is measurable. The compiler can't skip arbitrary output directories — only `node_modules` and `saltygen/` are excluded by name. Keep generated directories out of your source tree, and avoid nesting `.css.ts` files inside paths that share a prefix with other deeply populated directories.
> Note: performance optimizations are on the roadmap
**3. Config pass (parallel)**
Config files run before anything else — they declare the design tokens and templates that every component file depends on. Each one goes through the same per-file compile step as everything else (see step 4), and its exports are sorted by what they declare: variables, templates, media queries, fonts, imports, and globals. `salty.config.ts` itself is compiled here as well, and its output is merged in.
The output of this pass is written into `saltygen/`: the global CSS layers (variables, reset, global, templates, fonts, imports), the generated TypeScript token/template/media-query types, and a serialised snapshot of the merged config — tokens, templates, media queries — that later steps can read without re-running the compiler.
**4. Per-file esbuild compilation**
This is the step that makes Salty CSS an _execution-based_ compiler, and where build performance is most sensitive.
Before esbuild ever sees the source, two things happen to it. First, any `styled(SomeNonSaltyComponent, …)` call is rewritten to `styled('div', …)`, so non-salty components don't drag their import chains into the bundle. Second, the cached config is made available to the file at evaluation time, so each file can resolve design tokens without re-compiling the config.
The patched source is then fed to **esbuild via stdin** (not as a file on disk), bundled, tree-shaken, and transpiled to a Node-compatible format. The result is written into `saltygen/js/` under a deterministic, content-derived filename — so an unchanged file produces the same path and can be reused by the incremental HMR path.
> **Perf:** esbuild is written in Go and is extremely fast — but it follows every `import` chain in the file. `packages: 'external'` excludes `node_modules`, but _local_ barrel files are a different matter. If your design tokens live in `./tokens/index.ts` and that file re-exports 50 individual token modules, esbuild follows all 50 chains for every `.css.ts` that imports it. Each file gets its own esbuild invocation, so a barrel with 50 re-exports across 80 style files means 4 000 extra module parses. Import from the specific token file you need, not a catch-all barrel.
**5. Dynamic `import()` + export bucketing**
The compiled `.js` is loaded into the running Node process with a dynamic `import()`. This is the step that separates Salty CSS from static AST tools: the code is _executed_ — any logic you wrote runs, any imports resolve, any `async/await` settles. The exports come back as a live JavaScript object.
Each named export is then inspected to work out what it is — a keyframes definition, a className generator, or a styled generator — and bucketed accordingly. Exports that resolve asynchronously (a bare `Promise`, or a function flagged to be awaited) are settled before they're bucketed.
> **Perf:** You _can_ `await fetch(...)` inside a style definition to pull tokens from a remote registry at build time. Each unresolved `await` serialises evaluation within that file's module. If 30 components each independently fetch from the same endpoint, you wait for 30 sequential requests. Batch-fetch once in a shared import and let the other files import the result instead — esbuild will inline the value, and the fetch runs once.
**6. CSS generation**
For each generator, the build-time context is locked in: the export name becomes the CSS filename prefix (`Button` → `cl_button-TzHVd-0.css`) and, in development, the value of the `data-component-name` attribute.
The generator then resolves and serialises its styles — which means it:
- Resolves `{token.path}` references to `var(--token-path)` CSS custom properties
- Expands template call sites (`textStyle: 'heading.large'` → the class that the template layer emits)
- Handles `@media` blocks, pseudo-selectors, and modifier rewrites declared in your config
- Serialises the result to a CSS string
The **hash** is computed from the style content itself — base, variants, and compound variants — not from the export name. It's content-addressed: rename the export and nothing changes; edit a style value and the hash changes. Two components with structurally identical style objects produce the same hash and collapse to a single CSS rule, deduplicated automatically.
> **Note on cascade order:** A `styled()` call whose first argument is another styled component increments `priority` by one. Higher-priority generators land in a later CSS layer (`l1`, `l2`, …). Because the `@layer` order is declared at the top of `index.css`, the cascade is deterministic: a more-specific component's rules always win over its base's rules, regardless of where the files live on disk or what order they were compiled in.
**7. Layer assembly + `index.css`**
Component CSS files are grouped by priority and merged into `l_0.css`, `l_1.css`, and so on — each wrapped in `@layer l0 { … }`. Each per-component block is delimited by `/*start:-*/` and `/*end:*/` markers. These markers are how the HMR incremental path patches a single component's CSS without triggering a full rebuild.
`index.css` is then assembled as the single entry point: a `@layer imports, reset, global, templates, fonts, l0, l1, …;` declaration at the top, followed by `@import` rules for each global file and each layer file. This is the file the user's app imports.
---
#### Funnel 2 — Load time: `saltygen/` → rendered component
The build funnel produces the CSS. This second funnel traces what happens when the bundler processes a `.css.ts` _import_ in your component tree, and what the browser ultimately receives.
```
Bundler load hook → source transform → minified client file → browser renders
```
**1. Bundler load hook**
When the bundler encounters a `.css.ts` import, the plugin's `load` hook fires. For React projects it runs a framework-specific transform that is loaded lazily and memoised for the entire bundler session, so the transform module loads once regardless of how many salty files exist.
**2. Source transform**
The per-file compile step runs again on the file (reusing the cached `.js` from `saltygen/js/` if the content hasn't changed). For each styled or className export, the generator's pre-computed client metadata is serialised — `{ hash, variantKeys, propValueKeys, defaultProps, attr }` — and the source is rewritten in place: the full style object is replaced with the baked-in client form.
```ts
// Your source
export const Button = styled("button", {
base: { padding: "{spacing.medium}" },
variants: { intent: { primary: { background: "{theme.primary}" } } },
});
```
```ts
// What the bundler serves to the browser
export const Button = styled("button", "TzHVd", {
hash: "TzHVd",
variantKeys: ["intent"],
propValueKeys: [],
defaultProps: {},
attr: { "data-component-name": "Button" }, // stripped in production
});
```
Import paths are also rewritten: `{ styled } from "@salty-css/react/styled"` becomes `{ styledClient as styled } from "@salty-css/react/styled-client"`. All style logic — the `base` object, token references, variant definitions — is gone from the bundle.
**3. Browser receives**
The client bundle contains no CSS serialisation engine, no CSSOM injection, no theme provider. The HTML element carries hashed class names (`class="TzHVd intent-primary"`). The static `saltygen/index.css` — already linked in `` — provides `.TzHVd { … }` and `.TzHVd.intent-primary { … }`. The browser applies them via its native cascade. Nothing computes anything.
**4. React render — server-first path**
In a React Server Component or an Astro component, there is no client bundle for that component at all. `styledClient` runs on the server, emits a string of class names baked into the HTML, and the component contributes zero JavaScript to the client payload. This is the "genuinely zero-runtime" case from the opening of this page.
**5. React render — client component path**
`styledClient` is the thin mapper that ships to the browser when you mark a component `'use client'`. It receives the pre-compiled client metadata, reads variant props at render time (e.g. `intent="primary"`), and appends the matching class name alongside the base hash class. If any `css-*` props are present (like `css-bg-color="tomato"`), it writes the corresponding CSS custom property as an inline style. No new classes are generated, no `
{children}
);
}
```
What ships to the browser is a `)}` in an RSC, or `{[...pairs.values()].map((css) => )}` in an `.astro` template.
### What features come along
`defineRuntime` runs the same parser as `styled`, so token substitution, `@media`, `&:hover`, and modifiers all work. Things that need a component wrapper — `defaultVariants`, `passProps`, `element`, `as`, `keyframes` — do not. Pick the active variant yourself before calling `resolve(...)` and pass a flat object.
See the [defineRuntime reference](/docs/api/runtime/) for the full support matrix and pitfalls (treating CMS-supplied CSS as untrusted, the `variants` "all branches emit" caveat, scoping options).
### Gotchas
- **Static export doesn't get per-request rendering.** A Next.js project with `output: "export"` runs at build time only — the tenant color you resolve there is whichever tenant the build saw. Switch to a regular server render (no `export`) for true request-time theming.
- **Treat CMS-supplied CSS as untrusted text.** The parser does not sanitize property values. Validate at the CMS boundary if editors are not trusted.
- **Don't put `defineRuntime` in a `*.css.ts` file.** The compiler ignores its output — `defineRuntime` is a request-time helper, not a build-time factory.
---
# Build a Theme Toggle With No Runtime CSS — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/theme-toggle/
Dark mode is the single most common ask for a CSS-in-JS library — and the place most of them ship runtime overhead. With Salty CSS, both themes are compiled into a single stylesheet keyed off a `data-theme` attribute on a root element. The "toggle" is just an attribute flip; no provider, no context, no extra bundle.
### Declare the tokens once
Themes live as a [conditional variable group](/docs/theming/) — each leaf token resolves through `var(--theme-…)` and the variant set you pick wins via the data attribute selector on `html`.
```ts
// /styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
theme: {
base: {
background: "white",
color: "#111",
highlight: "#0070f3",
},
"[data-theme=dark]": {
background: "#0b0d12",
color: "#f5f5f5",
highlight: "#7aa7ff",
},
},
});
```
### Use the tokens in any
```ts
// /components/panel.css.ts
import { styled } from "@salty-css/astro/styled";
export const Panel = styled("section", {
base: {
background: "{theme.background}",
color: "{theme.color}",
padding: "2rem",
borderRadius: "0.5rem",
transition: "background 0.2s ease, color 0.2s ease",
},
});
```
### Flip the attribute
The minimal "toggle" is a button that writes `data-theme` on `documentElement`. Persist it in `localStorage`, and inline a tiny pre-hydration script to avoid a flash of the wrong theme on first paint:
```astro
---
// /src/components/ThemeToggle.astro
---
```
The `is:inline` directive ensures the script runs before the first paint, so users never see a flash of the wrong theme.
### What ships to the browser
The compiler emits both theme branches into `saltygen/index.css` — one block of declarations under the base selector, one under `[data-theme=dark]`. The runtime cost of switching is zero JavaScript styling work: the browser re-resolves `var(--theme-…)` after the attribute changes. Nothing about Salty CSS is loaded on the client to make this work.
### Gotchas
- **First-paint flash.** Statically exported pages render with the default theme until JS runs. The pre-hydration script in the snippet above sets `data-theme` before React/Astro hydrates, so the correct theme is in place from the first frame.
- **System preference.** `prefers-color-scheme: dark` is honoured by the read-from-storage logic in the snippet — the user's explicit choice always wins over the OS.
- **Server-rendered initial state.** In the Next.js App Router, the root `` is rendered on the server. You can't read `localStorage` there; render with no `data-theme` and let the inline script set it before paint.
---
# Decouple a Heading's Tag From Its Looks — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/polymorphic-heading/
Most landing pages want one visual headline style that has to render as `
` at the top of the page, `
` inside a section, and sometimes `` inside a card. Plain CSS-in-JS usually solves this with one component per tag, all importing the same styles — which gets old fast.
Salty CSS's `styled()` takes an `element` option for the default tag, and the rendered component accepts an `as` prop for per-instance overrides. The styles compile once.
### The component
```ts
// /components/heading.css.ts
import { styled } from "@salty-css/astro/styled";
// Default tag is
; `as` swaps it at the call site.
export const Heading = styled("h2", {
base: {
fontFamily: "system-ui, sans-serif",
fontWeight: 700,
lineHeight: 1.1,
letterSpacing: "-0.02em",
margin: 0,
},
variants: {
size: {
sm: { fontSize: "1.25rem" },
md: { fontSize: "1.75rem" },
lg: { fontSize: "2.5rem" },
xl: { fontSize: "3.5rem" },
},
tone: {
default: { color: "{theme.color}" },
muted: { color: "{theme.altColor}" },
brand: { color: "{theme.highlight}" },
},
},
defaultVariants: { size: "md", tone: "default" },
});
```
### Using it
The `as` prop overrides the rendered tag without changing the style props. Variant props (`size`, `tone`) stay typed.
```astro
---
// src/pages/landing.astro
import { Heading } from "../components/heading.css";
---
Build interfaces that ship as plain CSSWhat you'll learnRecipe
```
In `.astro` templates the `as` prop reads the same way as in JSX — Salty's Astro runtime picks the right tag and writes the generated `class` (note: `class`, not `className`) onto the rendered element.
### What it produces
`styled("h2", { … })` emits one class per `base` and per variant. Switching `as` does **not** create a new class — Salty just picks a different element type to render into ( chooses the right tag at render time). The runtime overhead is the same conditional Salty already does for every styled component (a tiny class-picking helper). No bundle is shipped for the styling.
### Gotchas
- **Headings are an SEO contract.** Pick `as` based on the document outline, not the visual size. The whole point of the recipe is that the two are now independent — use it.
- **`defaultVariants` apply before `as`.** Passing `as="span"` keeps the `size`/`tone` defaults; it doesn't reset them.
- **Wrapping a third-party component?** Pass it as the first argument to `styled(...)` and add `passProps` for the props the wrapped component needs. The `as` prop still works on the result. See the [Overrides](/docs/overrides/) reference.
---
# Status Badges Whose Shared Rules Never Block Local Overrides — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/zero-specificity-badges/
The classic CSS-in-JS specificity trap: you write a `Badge` with a shared "loud" style for the success/warning/danger tones, then a consumer tries to override the font weight on one instance and finds their style loses. Salty CSS handles this with [`anyOfVariants`](/docs/api/styled/#anyofvariants) — the shared rule is wrapped in `:where()` so it carries zero specificity and any later rule for the same property wins automatically.
### The component
```ts
// /components/badge.css.ts
import { styled } from "@salty-css/astro/styled";
export const Badge = styled("span", {
base: {
display: "inline-block",
padding: "0.125rem 0.5rem",
borderRadius: "999px",
fontSize: "0.75rem",
},
variants: {
tone: {
success: { background: "#16a34a", color: "white" },
warning: { background: "#eab308", color: "black" },
danger: { background: "#dc2626", color: "white" },
neutral: { background: "#e5e7eb", color: "#111" },
},
},
// Apply the same "loud weight" whenever the tone is brand-loud.
// anyOfVariants emits :where(…) so this rule has zero specificity and
// always loses to a per-instance override.
anyOfVariants: [
{ tone: "success", css: { fontWeight: 700, letterSpacing: "0.02em" } },
{ tone: "warning", css: { fontWeight: 700, letterSpacing: "0.02em" } },
{ tone: "danger", css: { fontWeight: 700, letterSpacing: "0.02em" } },
],
});
```
### Why this beats the obvious alternatives
- Repeating `fontWeight: 700` inside each `variants.tone` branch works, but every new tone has to remember to copy it — and merging two of those into a compound override later is awkward.
- Adding it to `base` and trying to "un-bold" the neutral tone via `{ fontWeight: 400 }` also works — until a parent style sheet (Tailwind reset, design-system base) wins on specificity. `anyOfVariants` ducks that fight entirely.
### Per-instance override
Because the shared rule lives behind `:where()`, the override below wins without `!important` or extra class chaining:
```astro
Optional
```
The inline `style` is the simplest demonstration — inline declarations always win. But even a more disciplined override — a separate rule keyed off `data-quiet="true"` — wins too, because the shared rule is wrapped in `:where(.badge-…)` and carries zero specificity.
### What it produces
For the four tones you get one base class, four tone branches, and one `:where(...)` rule covering the three "loud" tones at once. All of it is in `saltygen/index.css` after the build; nothing about specificity is computed at render time.
### See also
- [`anyOfVariants` in the styled API reference](/docs/api/styled/#anyofvariants)
- [Variants](/docs/variants/) — the broader picture of variant composition.
- [Overrides](/docs/overrides/) — when to reach for `priority` instead.
---
# Fluid Typography From One Helper, No Media-Query Stair-Steps — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/fluid-typography/
Most "responsive headline" code is three or four `@media` blocks doubling the font size at each breakpoint. The result is fine at the breakpoints and a little janky in between. CSS `clamp()` fixes the jank — but typing the right `clamp(min, fluid, max)` for every size is tedious. Salty CSS ships [`defineViewportClamp`](/docs/viewport-clamp/) so you author the helper once and call it with the reference size.
### Define the helpers
```ts
// /styles/helpers.css.ts
import { defineViewportClamp } from "@salty-css/core/helpers";
// Reference at a 1920px desktop viewport.
// Output can shrink to 50% on small screens and grow to 125% on 4K.
export const fhdClamp = defineViewportClamp({
screenSize: 1920,
minMultiplier: 0.5,
maxMultiplier: 1.25,
});
// A second helper for mobile-anchored values.
export const mobileClamp = defineViewportClamp({
screenSize: 640,
minMultiplier: 0.75,
maxMultiplier: 1,
});
```
### Use it in the
```ts
// /components/headline.css.ts
import { styled } from "@salty-css/astro/styled";
import { fhdClamp, mobileClamp } from "../styles/helpers.css";
export const Headline = styled("h1", {
base: {
// Scales from ~48px on a phone to 120px on a 4K screen,
// perfectly fluid in between. One declaration, no @media stack.
fontSize: fhdClamp(96),
lineHeight: 1.05,
margin: 0,
// For small viewports, swap to a mobile-anchored clamp so the
// floor doesn't drop too aggressively at 320px.
"@largeMobileDown": {
fontSize: mobileClamp(48),
},
},
});
```
### What it produces
`fhdClamp(96)` resolves at build time to a plain `clamp(…, …vw, …)` expression — the value is baked into `saltygen/index.css`. There is no `defineViewportClamp` call left in your bundle; the browser handles all the math from then on. Resize-listener gymnastics are not needed.
### When to reach for it (and when not)
- **Reach for it** for headlines, hero paddings, page gutters — anything you want to scale "with the viewport," not "at three breakpoints."
- **Don't reach for it** when you actually want a stepped feel (a `1.25rem → 1.5rem` jump at a design-system breakpoint). Variants or a media query express that intent more clearly.
- **Combine with `defineVariables`** when you want the clamp values to live as design tokens. See the [Viewport Clamp utility](/docs/viewport-clamp/) for the responsive-tokens pattern.
### Gotchas
- **Reference size matters.** `screenSize: 1920` means "96px is the value at 1920px viewport." If your audience is mostly on 1366px laptops, anchor closer to that — or your `min` will pin everywhere.
- **Multipliers, not absolute caps.** `minMultiplier: 0.5` is "50% of `96`" = 48px. Pass an explicit min as the second arg (`fhdClamp(96, 42)`) when you want a hard floor.
- **Vertical axis exists.** Pass `axis: 'vertical'` to the helper for layouts that key off `vh` instead of `vw` (mobile portrait, full-bleed media).
---
# Auto-Generated Hover and Active Shades With color() — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/color-function-shades/
Hover and active states usually start as "make it a bit darker." Then a designer changes the brand color, and you remember the six places you eyeballed the shade. Salty CSS's [`color()` helper](/docs/color-function/) is the right tool: pass one color in, derive `.lighten()` / `.darken()` / `.alpha()` shades from it. The transformation runs at build time, so the generated CSS is a static string — no client-side color math, no bundle bloat.
### The component
```ts
// /components/button.css.ts
import { styled } from "@salty-css/astro/styled";
import { color } from "@salty-css/core/helpers";
const brand = "#0070f3";
export const Button = styled("button", {
base: {
background: brand,
color: "white",
border: "1px solid transparent",
borderRadius: "0.375rem",
padding: "0.5em 1em",
cursor: "pointer",
transition: "background 0.15s ease, transform 0.05s ease",
"&:hover": {
// 10% lighter on hover.
background: color(brand).lighten(0.1),
borderColor: color(brand).darken(0.15),
},
"&:active": {
// 10% darker, slight nudge.
background: color(brand).darken(0.1),
transform: "translateY(1px)",
},
"&:disabled": {
// Desaturated and translucent — clearly inactive.
background: color(brand).desaturate(0.5).alpha(0.6),
cursor: "not-allowed",
},
},
});
```
### Or derive from a theme token
`color()` parses static token references too, so you can keep the source of truth in `defineVariables`:
```ts
import { color } from "@salty-css/core/helpers";
export const PrimaryButton = styled("button", {
base: {
background: "{colors.brand.primary}",
color: "white",
"&:hover": {
background: color("{colors.brand.primary}").lighten(0.1),
},
"&:active": {
background: color("{colors.brand.primary}").darken(0.1),
},
},
});
```
The token has to be **static** — i.e. defined once in `defineVariables` without a conditional branch. The build can only transform a value it knows about. For shades on a themed token that changes between dark/light, declare the shade variants on the conditional variable itself; see the [Color Function note](/docs/color-function/) on when transformations happen.
### What it produces
After the build, `color(brand).lighten(0.1)` is a plain `rgb(…)` string in `saltygen/index.css`. The `color()` import is tree-shaken out of the entirely. The browser sees a static stylesheet; the only "computation" left to it is matching `:hover` and `:active`.
### Gotchas
- **No runtime values.** Don't pass `color({props.bg})` — there's no way to derive a shade from a value that doesn't exist yet at build time. Use a CSS custom property and `color-mix()` if you need true runtime derivation.
- **HSL space.** `.lighten()` / `.darken()` operate on HSL lightness, which is usually what you want but can produce surprising results on highly-saturated source colors. Adjust the multiplier, or switch to a `.mix(...)` against `white` / `black` for a different curve.
- **`!important` and layers.** A consumer's `style=` will still win — `color()` produces ordinary stylesheet rules, not inline ones.
---
# Stagger a List Animation Without Writing a Class per Item — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/staggered-list-animation/
A "staggered" list animation — items fading in one after another — is one of those effects that ends up costing more than it should. The usual approaches: hand-author N keyframes, or compute `animationDelay` from a JS index at render time. Salty CSS does it with one keyframe and an `index` variant on the list item. The CSS for all five delays is compiled up-front; rendering each item is just picking the right variant class.
### Define the animation
```ts
// /styles/animations.css.ts
import { keyframes } from "@salty-css/astro/keyframes";
export const fadeIn = keyframes({
// Apply the "0%" state immediately so delayed items don't flash
// their final state before the animation begins.
appendInitialStyles: true,
params: { duration: "350ms", easing: "ease-out", fillMode: "both" },
from: { opacity: 0, transform: "translateY(8px)" },
to: { opacity: 1, transform: "translateY(0)" },
});
```
### The list item
```ts
// /components/staggered-item.css.ts
import { styled } from "@salty-css/astro/styled";
import { fadeIn } from "../styles/animations.css";
export const StaggeredItem = styled("li", {
base: {
animation: fadeIn,
},
variants: {
index: {
0: { animationDelay: "0s" },
1: { animationDelay: "0.08s" },
2: { animationDelay: "0.16s" },
3: { animationDelay: "0.24s" },
4: { animationDelay: "0.32s" },
},
},
});
```
### Render the list
Cap the index at the highest variant so a longer list still works — it just stops adding delay past the fifth item.
```astro
---
// src/pages/items.astro
import { StaggeredItem } from "../components/staggered-items.css";
const items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"];
---
{items.map((item, index) => (
{item}
))}
```
### What it produces
The compiler emits one `@keyframes` block for `fadeIn` and five tiny rules — one per `index` value — in `saltygen/index.css`. No `animationDelay` arithmetic happens at render time; each list item just picks the variant class that matches its `index` prop.
### Gotchas
- **Respect `prefers-reduced-motion`.** Wrap the animation in a media query that disables it for users who opted out — see the [Animations guide](/docs/animations/) for the pattern.
- **`appendInitialStyles: true` matters.** Without it, items render at their `to` state for the duration of the delay, then snap to the `from` state when the animation kicks in. With it, the starting style is applied directly, so the cascade looks clean from the first paint.
- **Cap the index.** The recipe above pre-defines five variants. If your list is longer, decide whether you want the cap (looks fine for ~10+ items) or to add more variants — adding 20 variants is cheap because each is a tiny rule.
---
# Beat a Third-Party Reset Without Reaching for !important — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/priority-overrides/
When a global reset or a third-party stylesheet wins against your component, the wrong answer is "add `!important` and move on." The right answer in Salty CSS is to lift the component into a higher CSS cascade layer with the [`priority`](/docs/api/styled/#priority) option. Layers always win over each other regardless of selector specificity — and `priority` is just an integer (0–8) you set on the styled definition.
### Set `priority` on the override
```ts
// src/components/callout.css.ts
import { styled } from "@salty-css/astro/styled";
// Lifts these rules into @layer l2, so they beat any l0/l1 rule
// at equal selector specificity — without changing the selector itself.
export const Callout = styled("div", {
priority: 2,
base: {
background: "{colors.brand.main}",
color: "white",
padding: "1rem",
},
});
```
The emitted CSS lands inside `@layer l2 { ... }`. Layers are declared in cascade order `l0, l1, …, l8`, so a higher number always wins.
That `priority: 2` lands the rules in `@layer l2`. Salty CSS declares the layer order globally as `imports, reset, global, templates, fonts, l0, l1, l2, … l8`, so a rule in `l2` beats anything in `l0` or `l1` even if both have the same selector specificity — and beats a global reset that lives in `reset`.
### When a wrapper auto-bumps and when it doesn't
Salty CSS automatically raises the priority by one when you extend another component:
```ts
import { styled } from "@salty-css/astro/styled";
import { Button } from "./button.css";
// Button is priority 0 (l0). PrimaryButton is auto-bumped to l1.
export const PrimaryButton = styled(Button, {
base: { background: "{colors.brand.main}", color: "white" },
});
```
This is usually what you want — the wrapper sits above what it wraps. But it's a tie-breaker, not a fight-stopper:
```ts
// src/components/button.css.ts
import { styled } from "@salty-css/astro/styled";
// Lives in @layer l0 — `color: red` for any plain .
export const Button = styled("button", {
base: { color: "red", padding: "0.5rem 1rem" },
});
// src/components/primary-button.css.ts
import { styled } from "@salty-css/astro/styled";
import { Button } from "./button.css";
// styled(Button, …) auto-bumps priority to 1, so this rule lives in
// @layer l1. Both rules have identical selector specificity, but
// l1 sits later in the cascade than l0 — `color: blue` wins.
export const PrimaryButton = styled(Button, {
base: { color: "blue" },
});
```
Salty CSS does not rely on source order to decide ties — only layer order. Wrap a component to win; don't reach for `!important` or more specific selectors.
If you set `priority: 0` explicitly on the wrapper, Salty CSS honours your intent and the auto-bump is suppressed.
### Why not `!important`?
Inside CSS cascade layers, `!important` declarations follow an **inverted layer order** — declarations marked `!important` in the *reset* layer beat `!important` declarations in `l0`/`l1`/etc. That interacts badly with the layered priority system Salty CSS already gives you, and surprises everyone reading the rule a year later. Stick with `priority` for cross-layer overrides and reach for an inline `style` only when you genuinely want "wins everything, including the layer system."
### What it produces
`priority` does not change the selector or add extra class chaining. The emitted CSS just lives inside `@layer l2 { … }` instead of `@layer l0 { … }`. The browser cost is zero; layer ordering is a static, declarative property of the stylesheet.
### See also
- [Overrides → Priority & cascade in depth](/docs/overrides/#priority--cascade-in-depth) — equal-specificity tie-breaks, inline `style`, modifier interactions.
- [`styled` reference → priority](/docs/api/styled/#priority) — the layer table.
---
# A Single-Instance Override via a CSS Variable, Not a New Variant — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/css-variable-escape-hatch/
Sooner or later a designer asks for "the same card, but with a bigger radius — just on this one page." If you add a `radius` variant for the one-off, the API grows; if you reach for `!important`, the next override fight is even harder. Salty CSS's preferred answer is to expose the knob as a CSS custom property in the component and override it at the call site via the framework-native `style` prop.
### The component declares the knob
```ts
// /components/themed-box.css.ts
import { styled } from "@salty-css/astro/styled";
export const ThemedBox = styled("div", {
base: {
backgroundColor: "var(--box-bg, white)",
color: "var(--box-text, black)",
padding: "var(--box-padding, 1rem)",
borderRadius: "var(--box-radius, 4px)",
},
});
```
The component reads each knob through `var(--name, fallback)`, so consumers can override one knob, all of them, or none — the fallback keeps untouched knobs working.
### Override at the call site
The framework-native `style` (or `style="…"` in Astro) attribute on the element itself — or any ancestor — sets the variable. Inline declarations always win the cascade, so the override lands even against a higher-priority Salty layer rule.
```astro
---
// src/pages/index.astro
import { ThemedBox } from "../components/themed-box.css";
---
This box uses the parent's custom properties
```
### Why this beats the obvious alternatives
- **A new variant for every knob** balloons the component's prop API and makes refactoring painful.
- **`!important`** wins the round but creates a worse override fight the next time someone needs a one-off.
- **The `style` prop on the component itself** also works (`style=`), but it only overrides on that element. Setting a CSS variable on a *parent* lets a whole subtree of components inherit the override.
### Typed prop tokens — when you want the API to be discoverable
If consumers shouldn't have to remember the variable name, declare a [prop token](/docs/overrides/#typed-prop-tokens-css--props) instead. Reference the value as `{props.X}` in your styles and Salty CSS exposes a typed `css-X` prop on the rendered component:
```ts
import { styled } from "@salty-css/astro/styled";
export const Box = styled("div", {
base: {
padding: "1rem",
color: "{props.color}",
backgroundColor: "{props.bgColor}",
},
});
// Usage: ``
```
Reach for prop tokens when the component owns the contract; stick with `style=` when the variable name is the contract (e.g. shared theming variables a parent wrapper sets for several descendants).
### What it produces
The component's CSS uses `var(--…)` references against a `--…` declaration that comes from the consumer's inline `style`. There is no extra Salty CSS rule emitted per call site — the inline declaration *is* the override.
### See also
- [Overrides → CSS Custom Properties](/docs/overrides/#css-custom-properties) — the broader pattern.
- [Overrides → Typed prop tokens](/docs/overrides/#typed-prop-tokens-css--props) — when to upgrade to a typed `css-*` prop.
---
# Enforce a Design Scale With a Custom Modifier — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/custom-modifier-scale/
Design systems lose to drift: one engineer writes `padding: "14px"`, another writes `padding: "0.875rem"`, and the spacing scale a designer carefully built starts breaking apart. Salty CSS lets you build a **proprietary DSL** for your design system with [`defineConfig` modifiers](/docs/modifiers/). A modifier is a regex pattern plus a transform function — when Salty CSS sees a matching value at build time, it rewrites it. The pattern is yours to design; the enforcement is automatic.
### Register the modifier
```ts
// /salty.config.ts
import { defineConfig } from "@salty-css/core/config";
export const config = defineConfig({
modifiers: {
// Shorthand: write `space:3` and get `12px` (3 × 4px base unit).
spaceShorthand: {
pattern: /^space:(\d+)$/,
transform: (match) => {
const n = Number(match.replace("space:", ""));
return { value: `${n * 4}px` };
},
},
// Inject extra CSS alongside the rewritten value:
elevation: {
pattern: /^elevation:(\d+)$/,
transform: (match) => {
const level = Number(match.replace("elevation:", ""));
return {
value: `${level * 2}px ${level * 4}px ${level * 6}px rgba(0,0,0,0.12)`,
css: { transform: "translateZ(0)" }, // emitted alongside
};
},
},
},
});
```
Use the shorthand at the call site:
```ts
styled("div", {
base: {
padding: "space:3", // → 12px
boxShadow: "elevation:2", // → 4px 8px 12px rgba(0,0,0,0.12)
},
});
```
`spaceShorthand` matches strings like `"space:3"` and rewrites them to `"12px"`. The transform receives the full matched string (not the capture groups) — re-parse it inside the function to extract the numeric portion.
### Use the DSL in any
```ts
// /components/card.css.ts
import { styled } from "@salty-css/astro/styled";
export const Card = styled("article", {
base: {
// Reads as "padding step 4 on the design scale" — the regex
// ensures only valid scale steps compile, off-scale values fail loudly.
padding: "space:4",
gap: "space:2",
borderRadius: "space:1",
},
});
```
### Why this beats the obvious alternatives
- **A `padding` variant** for every scale step explodes the variant API and ties spacing to component identity.
- **Design tokens via `defineVariables`** are great for values, but they don't surface as a fluent shorthand at the call site. A modifier is intentionally regex-driven, so unmatched values fall back to plain CSS without the developer having to think about it.
- **Code review** as the enforcement layer is human, slow, and inconsistent. A modifier moves the check to build time.
### What it produces
The matched string is replaced with the transform's return value before the parser sees it. After the build, `padding: "space:4"` is `padding: 16px` in `saltygen/index.css` — no trace of the DSL is left in the bundle. Off-scale values don't match the pattern and pass through unchanged; if you want hard enforcement you can throw inside the transform on illegal inputs, and the build will fail.
### Gotchas
- **Anchor the pattern.** `/^space:(\d+)$/` is right; `/space:(\d+)/` will match `"space:3 extra"` and break with surprising results. The `^…$` anchors keep the rewrite explicit.
- **Strings only.** Modifiers run against string values. `padding: 12` (numeric) is handled by `defaultUnit` on `defineConfig`, not by modifiers.
- **One scale, one source of truth.** If your modifier is "multiply by 4", document that. Future readers won't reverse-engineer the curve from the transform.
- **Inject extra CSS sparingly.** A modifier can return `{ value, css }` to emit additional declarations next to the rewritten property (the snippet above includes an `elevation` example). It's powerful for shadows and gradients, but easy to abuse.
### See also
- [Modifiers reference](/docs/modifiers/) — pattern, transform, `css` side-effects, and when to reach for tokens or templates instead.
- [`defineConfig` reference](/docs/api/config/#modifiers) — where modifiers live in the config.
---
# A Per-Tenant Brand Color Resolved on the Server — Recipe (Astro)
Source: https://salty-css.dev/recipes/astro/runtime-tenant-theme/
Multi-tenant SaaS apps regularly need to "white-label" — render the same product with a different brand color per tenant, looked up at request time from a database, env, or CMS. Plain CSS-in-JS solves this by shipping a styling runtime that re-resolves variables on the client. Salty CSS solves it with [`defineRuntime`](/docs/api/runtime/): the same parser the build-time compiler uses runs on the server, returns a `{ className, css }` pair, and you render the CSS inline next to the element. Nothing styling-related is sent to the browser as JavaScript.
> **Frameworks:** this recipe targets server-render boundaries — Next.js App Router server components and Astro SSR routes. The pure-React/Vite path has no equivalent request-time rendering context; for that, ship a regular Salty CSS [conditional variable](/docs/theming/) keyed off a `data-tenant` attribute instead.
### Define the runtime
`defineRuntime` is a regular `.ts` import — *not* a `*.css.ts` file. Pass it your project config so `{colors.brand.primary}` and friends resolve to the same CSS variables your build-time styles already use.
```ts
// /lib/runtime.ts
import { defineRuntime } from "@salty-css/astro/runtime";
import config from "../../salty.config";
export const runtime = defineRuntime(config);
```
### Render a tenant-tinted card
```astro
---
// /src/components/TenantCard.astro — runs on the server per request
import { runtime } from "../lib/runtime";
interface Tenant {
id: string;
brandPrimary: string; // e.g. "#7c3aed", resolved from DB per request
}
const { tenant } = Astro.props as { tenant: Tenant };
const { className, css } = await runtime.resolve({
background: tenant.brandPrimary,
color: "{colors.text.onBrand}",
padding: "1.5rem",
borderRadius: "0.5rem",
"&:hover": { filter: "brightness(1.08)" },
"@media (min-width: 600px)": { padding: "2rem" },
});
---
```
What ships to the browser is a `)}` in an RSC, or `{[...pairs.values()].map((css) => )}` in an `.astro` template.
### What features come along
`defineRuntime` runs the same parser as `styled`, so token substitution, `@media`, `&:hover`, and modifiers all work. Things that need a component wrapper — `defaultVariants`, `passProps`, `element`, `as`, `keyframes` — do not. Pick the active variant yourself before calling `resolve(...)` and pass a flat object.
See the [defineRuntime reference](/docs/api/runtime/) for the full support matrix and pitfalls (treating CMS-supplied CSS as untrusted, the `variants` "all branches emit" caveat, scoping options).
### Gotchas
- **Static export doesn't get per-request rendering.** A Next.js project with `output: "export"` runs at build time only — the tenant color you resolve there is whichever tenant the build saw. Switch to a regular server render (no `export`) for true request-time theming.
- **Treat CMS-supplied CSS as untrusted text.** The parser does not sanitize property values. Validate at the CMS boundary if editors are not trusted.
- **Don't put `defineRuntime` in a `*.css.ts` file.** The compiler ignores its output — `defineRuntime` is a request-time helper, not a build-time factory.