→ TypeScript, one CLI, one ESLint plugin — and a type layer your own definitions generate.
Tooling
Salty's tooling is small on purpose: TypeScript, one CLI, one ESLint plugin. There's no language server to install, no editor extension, no separate compiler binary to keep on your PATH. The compiler runs inside your bundler, and everything else on this page is a package that's already in your package.json.
The part worth knowing up front is that most of the type layer isn't written by hand — not by me, and not by you. Your own definitions generate it. Add a color token and {colors.brand.main} starts autocompleting; name a media query and "@tabletDown" becomes a valid object key; define a text style and textStyle: "heading.large" shows up as a suggestion with the wrong paths greyed out. The types are a reflection of your design system, regenerated on every build.
Three things below: what the type layer knows and what it can't see, what the CLI does to your repo, and the two mistakes ESLint exists to catch — plus a pointer to where the browser half of this lives.
All three came out of the same place, and I'll admit it up front: laziness. The CLI is the setup I got tired of copy-pasting, and the lint rules are the two mistakes I got tired of fixing. Laziness always wins — it just occasionally has to build itself a tool first. 🧂
TypeScript: types you didn't write
How you define it
Nothing here is a "types" step — it's just the normal way you declare tokens, breakpoints, and templates:
import { defineVariables } from "@salty-css/core/factories";
export default defineVariables({
colors: {
brand: { main: "#0070f3", highlight: "#ff4081" },
},
spacing: { small: "8px", medium: "16px", large: "32px" },
});import { defineMediaQuery } from "@salty-css/react/config";
export const tabletDown = defineMediaQuery((media) => media.maxWidth(900));import { defineTemplates } from "@salty-css/core/factories";
export default defineTemplates({
textStyle: {
heading: {
base: { lineHeight: 1.1, fontWeight: 700 },
large: { fontSize: "2.5rem" },
small: { fontSize: "1.5rem" },
},
},
});How you use it
Every one of those shows up in the editor the next time you type inside a style object:
import { styled } from "@salty-css/react/styled";
export const Card = styled("section", {
base: {
padding: "{spacing.large}", // ← token paths autocomplete after `{`
background: "{colors.brand.main}",
textStyle: "heading.small", // ← template paths autocomplete too
"@tabletDown": { // ← so do your media query names
padding: "{spacing.medium}",
},
},
variants: {
tone: { neutral: {}, brand: { color: "{colors.brand.highlight}" } },
},
});And at the call site, the variants you declared are typed union props — tone accepts "neutral" or "brand" and nothing else, alongside all the normal props of the element you rendered:
<Card tone="brand">Salty</Card>That last one is worth pausing on, because it's the piece that pays off most over a year. A styled component isn't a class name you have to remember the contract for — the contract is the component's type. Import it and the editor tells you which states it has.
Where the types actually come from
During a build, alongside the CSS, the compiler writes a declaration file into saltygen/ describing your tokens, template paths, and media query names. That's what your editor is reading.
Two practical consequences fall out of that, and both are worth knowing before they confuse you:
Types follow the build. A fresh clone with no saltygen/ folder has no token suggestions yet, because nothing has generated them — run the dev server (or npx salty-css build) once and they appear. This is also why init adds a prepare script to your package.json: it runs a build after npm install, so a teammate who just cloned the repo gets both the stylesheet and the types without being told to.
Your editor can hold a stale copy. Add a token, and the suggestion sometimes doesn't appear until the TypeScript server picks up the regenerated file. Restarting the TS server in your editor is the fix — and knowing that up front saves you a genuinely baffling five minutes.
What TypeScript won't catch
Now the honest half, because a page that only lists the wins sets you up to be surprised later.
Style values are permissive by design. Every CSS property accepts arbitrary strings in Salty's types. That's deliberate — it's what lets modifiers, custom syntax, and one-off escape hatches work at all. The cost is real: "{colors.brnad.main}" is a perfectly valid string, so a typo'd token path isn't always a red squiggle in your editor.
The build is the thing that catches it, and init sets you up for that by writing strict: true into your generated salty.config.ts:
import { defineConfig } from "@salty-css/core/config";
export const config = defineConfig({
strict: true,
externalModules: ["react", "react-dom"],
});With strict on, an unresolvable token path fails the build instead of quietly emitting padding: {spacing.smal} into your stylesheet, where the browser drops it and you spend the afternoon wondering why one card is flat. 'warn' is the middle setting if you're mid-migration and can't be strict yet.
A wrong filename type-checks perfectly. Salty only compiles files ending in .css.ts, .css.tsx, .salty.ts, .styled.ts, or .styles.ts. Put the exact same code in card.ts and TypeScript is completely happy — it's valid code, the imports resolve, the component renders. It just produces no CSS. Nothing in the type system can see the problem, because the problem is the filename.
That's the gap the ESLint plugin exists to cover, below.
The CLI
The CLI ships inside @salty-css/core, so there's nothing to install globally:
npx salty-css [command]| Command | Alias | What it's for |
|---|---|---|
init | — | Set up Salty in a project: packages, config, plugin wiring, first build. |
generate | g | Scaffold a new component file with the right suffix and boilerplate. |
build | b | Compile the project's CSS on demand. |
update | up | Bump every @salty-css/* package in lockstep. |
The full flag list lives in the CLI reference. What follows is the part you actually need in daily use.
init — and exactly what it touches
npx salty-css init is the one command most projects run once. It's also the one that edits the most files, so here's the full list of what to expect in the diff:
- Detects your framework from
package.json(Next.js, Vite, Webpack, Astro) and picks the matching plugin package. - Installs the packages — it prints the list and asks before running anything.
--skip-installskips it if you'd rather add them yourself, and-yskips the prompt. - Writes
salty.config.tsnext to your bundler config, withstrict: truealready on. - Seeds
saltygen/and adds it to.gitignore— it's a build artifact, regenerated from scratch every time, and it should never be committed or hand-edited. - Writes
.saltyrc.jsonat the repo root. More on that in a second, and unlikesaltygen/, this one is meant to be committed. - Wires the bundler plugin into
next.config.ts/vite.config.ts/astro.config.mjs. - Adds the stylesheet import — it looks for a global CSS file and prepends
@import '…/saltygen/index.css';. If it can't find one, it warns and leaves it to you.--css-filepoints it at the right file directly. - Wires ESLint if it finds a config to wire — see the ESLint section.
- Adds a
preparescript (npx salty-css build) so a freshnpm installproduces the CSS and the types. - Runs the first build, so you can start writing components immediately.
Two honest caveats. First, init installs with npm — if your project is on pnpm, yarn, or bun, run it with --skip-install and add the printed packages with your own package manager. Second, in a monorepo, run it from the package's own root rather than the workspace root, or the framework detection reads the wrong package.json. If it does pick wrong, delete the generated salty.config.ts and re-run; a clean re-init is a much shorter path than untangling half-written config by hand.
.saltyrc.json — define once, then type less
This is the small file that makes every other command shorter. init writes it; you mostly forget it exists.
{
"$schema": "./node_modules/@salty-css/core/.saltyrc.schema.json",
"defaultProject": "apps/web/src",
"projects": [
{
"dir": "apps/web/src",
"framework": "next",
"include": ["src/**"],
"exclude": [".next/**", "out/**"]
}
]
}Because defaultProject is set, this works from anywhere in the repo:
npx salty-css build # instead of: npx salty-css build apps/web/srcIn a monorepo you run init once per app and each one appends its own entry, so the CLI knows all of them. The include / exclude globs are sensible defaults for narrowing the compiler's file walk — worth revisiting if you have a large repo and the build feels slower than it should.
generate — scaffolding with the right filename
The command exists mostly so nobody has to remember the suffix rules:
npx salty-css generate components/button --name Button --tag buttonThat writes components/button.css.ts (the .css is inserted for you if you leave it out) containing an exported, correctly-named styled component ready for its base block. --className adds a custom class alongside the hash, and -r / --reactComponent additionally scaffolds a wrapper component file next to the styled definition when you want the two split.
build — when the plugin isn't doing it for you
With a bundler plugin wired up, your dev server already rebuilds on save and you'll rarely type this. It earns its place in four situations: a CI step, the prepare script, debugging a build you want to run in isolation, and any setup where the plugin isn't running.
npx salty-css build --watch # rebuild on change, no dev server
npx salty-css build --mode production # override NODE_ENV-based detection--mode is the one to reach for when you want to check what actually ships — development builds include the debugging attributes described below, production builds strip them.
update — the pre-1.0 one
Salty is pre-1.0, and the packages are versioned in lockstep. Mixing versions across @salty-css/core, @salty-css/react, and your bundler plugin is the kind of failure that produces confusing errors rather than clear ones, so there's a command for exactly that:
npx salty-css up # every @salty-css/* package to latest
npx salty-css up 0.4.2 # or pin them all to one versionAnd when something's off and you're about to open an issue, this prints the CLI version plus every Salty package version in your package.json — paste it in:
npx salty-css --versionESLint: the two rules
TypeScript can only check what the type system can see, and two of Salty's most-hit mistakes are invisible to it — both are perfectly valid TypeScript that compiles, runs, and produces the wrong thing (or nothing). That's the entire reason the plugin exists. It isn't a style guide; it's two rules for two failures that have genuinely cost people an afternoon.
Setup
If init found an ESLint config in your project — eslint.config.js, eslint.config.mjs, or .eslintrc.json, in the project folder or at the repo root — it already installed @salty-css/eslint-config-core and added it to that file. Worth checking your diff; if it couldn't work out where to insert the config it says so and leaves the file alone.
To add it by hand:
npm i -D @salty-css/eslint-config-core// flat config, ESLint 9+
import saltyCss from "@salty-css/eslint-config-core/flat";
export default [saltyCss];{ "extends": ["@salty-css/eslint-config-core"] }Both rules are error by default, both are autofixable with --fix, and both bail out immediately on any file that isn't a Salty file — so the rest of your codebase sees nothing from this plugin.
must-be-exported
The compiler only picks up exported definitions. A styled, className, keyframes, or defineX call assigned to a plain const is dead code as far as the build is concerned: no error, no warning, no CSS.
import { styled } from "@salty-css/react/styled";
const Badge = styled("span", { // ✗ no export — compiles, renders, no styles
base: { padding: "0.25em 0.6em", borderRadius: "999px" },
});The failure mode is the nastiest kind: everything looks right. The component is there, the import works, the element renders — it's just unstyled, and nothing anywhere tells you why. The fix is one word, and the rule applies it for you:
export const Badge = styled("span", { // ✓
base: { padding: "0.25em 0.6em", borderRadius: "999px" },
});A bare top-level call gets export default prepended instead, which is the shape defineVariables and friends usually take.
no-variants-in-base
variants belongs beside base, not inside it. Nest it and you've written something that is still valid CSS-in-JS — so it compiles, and it emits real CSS:
export const Button = styled("button", {
base: {
padding: "0.6em 1.2em",
variants: { // ✗ inside base
tone: { solid: { background: "black", color: "white" } },
},
},
});Salty reads that nested key as a selector, so you get a rule targeting a child element literally named <variants>. Which is syntactically fine and semantically nonsense — the CSS is there, it's just permanently waiting for an element that will never exist. Meanwhile tone never becomes a prop, because no variants were ever declared.
export const Button = styled("button", {
base: { padding: "0.6em 1.2em" },
variants: { // ✓ sibling of base
tone: { solid: { background: "black", color: "white" } },
},
});The autofix moves the block out for you. This one is easy to write by accident when you're deep in a big style object and the indentation stops being a reliable guide — which is exactly the sort of thing a linter is better at noticing than a person is.
What it doesn't do
Two rules is a small surface, and I'd rather say that plainly than imply the plugin lints Salty semantics in general. These are the mistakes that showed up in real projects and produced confusing failures; things like cascade-layer conflicts, an unused export, or a token path typo aren't lint problems — the compiler and strict are the right tools for those. More rules may land as more patterns prove worth catching.
Reading your output in DevTools
Worth knowing about here even though it belongs to the next page over: in development builds every styled component renders with a data-component-name attribute taken from its export name, so [data-component-name="Card"] in the elements panel takes you straight to it. In production the attribute is stripped, so it costs nothing in shipped HTML.
The rest of it — the readable variant classes, the layer view that answers most override questions, editing tokens live on :root — is on Testing, where reading your own output is one of the three jobs the page is about.