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

→ The files you write, the files Salty writes, and how to read saltygen/ when something's wrong.

File structure

Two sets of files matter in a Salty project: the ones you write, where the only thing the compiler cares about is the filename — and the ones Salty writes, which you never edit but can read like any other text file.

That second half is worth more than it sounds. Salty is a build-time tool: your .css.ts files are executed when you build, and what comes out is a static stylesheet plus some class names in your HTML. So when something looks wrong, the answer is usually already sitting on disk under a name that tells you what it is. You just have to know which file to open.

What you write

The suffix is the contract

Salty finds style files by filename, not by import. There's no barrel file, no registration step, nothing to re-export. Five suffixes compile:

Example
button.css.ts   the default
button.css.tsx   same, when the file needs JSX
button.salty.ts
button.styled.ts
button.styles.ts

button.ts   type-checks perfectly, emits nothing

The .js, .jsx, .mjs and .cjs equivalents work too, though in practice you'll be writing TypeScript — the whole token layer is typed.

They're aliases, not variants: button.salty.ts and button.css.ts are treated identically. Pick one for the project and stay with it, because the day someone greps for *.css.ts to find every style file is the day a stray .styles.ts gets missed.

The second half of the contract is the export. The compiler collects exported calls, so this emits nothing at all:

button.css.ts
const Button = styled("button", { base: { padding: "1rem" } }); // no export, no CSS

Neither mistake produces an error, which is exactly why the ESLint plugin exists — those two rules are most of what it's for.

Scaffold with the right name and you never think about it:

Example
npx salty-css generate src/components/button --name Button

Where the files sit

Salty doesn't care. It walks the project, skips node_modules and saltygen/, and takes whatever matches the suffix. So the layout is entirely yours, and the one most projects land on is: component styles next to the component, shared definitions in one folder.

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

Splitting by what a file defines keeps each one small and means you always know which file to open. Advanced setup goes through what actually goes in each of those.

Two things about that walk are worth knowing early, because they're the difference between "my file isn't compiling" and "my file was never looked at":

  • include / exclude in .saltyrc.json scope it. Files outside include are never discovered. In a big repo those globs are also how you keep the walk fast.
  • A file that calls any defineX( factory gets compiled twice — once in an earlier config pass, once with everything else. That's how tokens exist before the components that reference them. The flag is a plain text match on the file contents, so a defineVariables( sitting in a comment costs you a second compile for nothing.

The two config files

They do genuinely different jobs, and mixing them up is a common first-week confusion:

FileWhereTells SaltyCommitted?
salty.config.tsNext to your bundler configHow to build your CSS — tokens, templates, modifiers, the reset, strictYes
.saltyrc.jsonRepo rootWhere your projects are — which dirs, which framework, what to includeYes
saltygen/Project root(nothing — it's the output)No

.saltyrc.json is also the file that answers "where does the output go" and "where is the config read from", via two keys most projects never touch:

Example
{
  "defaultProject": ".",
  "projects": [
    {
      "dir": ".",
      "framework": "react",
      "configDir": ".",
      "saltygenDir": "saltygen",
      "include": ["src/**"],
      "exclude": ["dist/**"]
    }
  ]
}

configDir is where salty.config.ts is read from, saltygenDir is the output folder name. Defaults are the project root and saltygen. Configuration has both files in full.

One import, at the root

With the default importStrategy: 'root', everything Salty generates arrives through a single line in a global stylesheet that loads on every page:

Example
@import "../saltygen/index.css";

Some bundler plugins add it for you during init; some leave it to you. Either way it's one line, once, and it's the entire wiring between the two halves of this page. If the whole app looks unstyled after a clone or a deploy, this line — or a missing build — is nearly always why.

What Salty writes

The shape of saltygen/

It lives at your project root, next to package.json, and it's rebuilt from scratch on every full build:

Example
saltygen/
├── index.css              the only file your app imports
├── salty.config.js        your salty.config.ts, compiled
├── cache/
   └── config-cache.json  the merged config, resolved
├── css/                   every stylesheet the compiler emitted
├── imports/               CSS assets copied out of node_modules
├── js/                    what the compiler actually executed
├── temp/
└── types/
    └── css-tokens.d.ts    the generated token types

All six directories are created up front, whether or not they're used. An empty folder is normal — it means that feature isn't in play. Nothing in here is hand-editable; a full build wipes the lot and writes it again.

index.css — the entry point

Written last, and it's mostly a table of contents:

saltygen/index.css
/*!
 * Generated with Salty CSS (https://salty-css.dev)
 * Do not edit this file directly
 * Version 0.4.0-alpha.3
 */
@layer imports, reset, global, templates, fonts, l0…l8;

@import url('./css/_variables.css');
@import url('./css/_reset.css');
@import url('./css/_global.css');
@import url('./css/_templates.css');
@import url('./css/a_fadeIn.css');
@import url('./css/l_0.css');
@import url('./css/l_1.css');

Three things to read out of it.

The version in the banner is the package that wrote this folder. When a build behaves like a version you don't think you're running, that line settles it in about two seconds.

The @layer declaration is the cascade order, decided once, at the top, before any rule exists. That's Salty's entire override story — a rule in l1 beats a rule in l0 no matter what the selectors look like. Scoping and composition has the model.

Only non-empty files get imported, and this is the useful one for debugging. No _fonts.css line doesn't mean the write failed — it means the file came out empty, i.e. the compiler found no defineFont(). Same signal for _imports.css (defineImport), _global.css (defineGlobalStyles) and _templates.css (defineTemplates). An absent import is a discovery problem, not a write problem, and those are fixed in completely different places.

css/ — the prefix says who wrote it

Every filename in here starts with a namespace, and reading it tells you which part of the compiler produced the file:

PrefixExampleOne per
__variables.cssFixed-name global. Always written, sometimes empty.
cl_cl_button-eyjPN.cssstyled() or className() component.
a_a_fadeIn.csskeyframes() export.
l_l_0.cssCascade layer — every cl_ file at one priority, concatenated.
f_f_button-abcDEF.cssSource file. Only with importStrategy: 'component'.

The six globals

FileComes fromIf it's empty
_variables.cssdefineVariables + config.variablesNo tokens were discovered
_reset.cssThe built-in reset, or config.resetYou set reset: 'none'
_global.cssdefineGlobalStyles + config.globalNo global styles defined
_templates.cssdefineTemplates + config.templatesNo templates defined
_fonts.cssdefineFontNo fonts defined
_imports.cssdefineImportNo external CSS pulled in

_variables.css is the one worth opening by hand, because it's where a token stops being TypeScript and becomes something the browser understands. It's deliberately unlayered — plain :root, no @layer wrapper — so custom properties resolve without competing with anything:

saltygen/css/_variables.css
:root {
  --colors-black: #0a0a0a;
  --colors-alt-black: #1a1a1a;
  @media (max-width: 900px) { --spacing-page-margin: 24px; }
}
.theme-dark, [data-theme="dark"] { --theme-background: var(--colors-black); }

Static tokens land on :root. Responsive tokens land in a media block inside :root. Conditional tokens — the theming ones — land outside it, one rule per condition, with a dual selector so both a class and a data attribute flip them. Which is theming in its entirety: no provider, no re-render, one attribute and the browser re-resolves the var().

The bridge between the two sides is dash-casing: {colors.altBlack} in TypeScript becomes --colors-alt-black in CSS. When a var() comes out looking wrong, run the token path through that transform in your head before suspecting anything else.

cl_ — one file per component

This is the file you'll open most, so the naming is worth a close read:

Example
cl_<your-export-name>-<hash>.css
components/button.css.ts
export const HeaderButton = styled("button", {
  base: { padding: "0.66em 1.5em" },
  variants: { variant: { solid: { background: "{colors.black}" } } },
});
saltygen/css/cl_header-button-eyjPN.css
.eyjPN { padding: 0.66em 1.5em; }
.eyjPN.variant-solid { background: var(--colors-black); }

The filename carries your export name; the class inside is the bare hash. That's the whole mapping, and it runs in both directions — from a name in your editor to a file, or from an opaque class in DevTools to the export that produced it.

The hash is derived from the style object only — not the name, not the file path. Three consequences fall out of that, and each one explains a thing that otherwise looks like a bug:

  • Edit a padding, get a new hash. The class name is content, not identity.
  • Rename the export and the hash doesn't move. The filename changes; the class doesn't.
  • Two components with byte-identical styles get the same class. This docs site has five of them — cl_code-section-HVKNL.css, cl_quote-section-HVKNL.css and three more — all emitting .HVKNL. That's deduplication working as designed. It also means "I changed X and Y's styles changed too" is a real thing that can happen, if X and Y were identical.

A file named with a bare hash and no readable prefix means the compiler couldn't work out the export name — usually because there wasn't one.

Variants are appended to the same class rather than getting their own, which is why the Styles panel stays readable:

FeatureSelector
variants.eyjPN.variant-solid
compoundVariants.eyjPN.variant-solid.size-large
anyOfVariants.eyjPN:where(.variant-solid, .variant-ghost)

a_ — one file per keyframes

saltygen/css/a_fadeIn.css
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

The name is whatever you passed as animationName, kept exactly as you wrote it — a_fadeInFromLeft.css stays camelCase. If you find a file called a_ plus five random letters, that's the fallback for a keyframes() call with no animationName. Name it and the file becomes greppable. See animations.

l_ — the layer bundles

Each l_N.css is every component at priority N, concatenated into its cascade layer, with a fence around each block:

Example
@layer l0 {
/*start:dgEcKI-cl_button-eyjPN.css*/
.eyjPN { padding: 0.66em 1.5em; }
/*end:dgEcKI*/
}

Those markers aren't decoration — they're how an incremental rebuild patches one component's CSS without rewriting the file. They're also how you answer "is my rule actually in the bundle", which is a different question from "did the compiler emit it."

Priority decides which bundle a component lands in: an explicit priority wins; otherwise a styled() call wrapping another salty component gets its parent's priority plus one; otherwise zero. So l_0 is plain components, l_1 is a component styling a component, and so on.

Two expectations to set. l_4 through l_8 are declared in index.css but only written if something uses them — missing files there are normal. And the declared list stops at l8, so a component with priority: 9 lands in an undeclared layer, which CSS sorts after every declared one. That's a big hammer, and it's occasionally the one you want.

f_ files only appear with importStrategy: 'component', where each source file gets a small file of @import lines so the framework can pull CSS in per component. In that mode no l_ bundles are written at all.

types/css-tokens.d.ts — where the autocomplete comes from

Every token path, template path, template variant and media query name, as TypeScript types. It's a global declaration file, so nothing imports it — it just makes {colors.black} autocomplete inside a style object and "@tabletDown" a valid key.

The debugging value is in one specific failure. Empty unions degrade to '' rather than never, so if you open the file and find VariableTokens = '', the compiler discovered no variables at all. That's almost never a broken defineVariables — it's a file the walk never reached. Check the suffix, check the export, check include/exclude.

The working folders

js/ holds every salty file after esbuild, under <source-hash>-<content-hash>.js. This is the most useful folder on the page and the least obvious one: it's what the compiler actually executed, after your imports were bundled and the config snapshot was injected. When a value in the output isn't what you wrote, this is where you find out what the compiler thought you wrote.

There are more files in here than you have source files, and that's expected. Config files compile twice, once per pass, and the two passes produce different content hashes. During a long dev session each edit leaves another artifact behind, so the folder grows; a full build wipes it.

cache/config-cache.json is the merged, resolved config — everything from salty.config.ts plus every defineX() call the compiler found, in one object. It exists because it gets injected back into each file before compilation, which is how a .css.ts file can reference {colors.black} without importing anything. Its templatePaths field is the useful one: it maps each template export to the exact js/ artifact it came from.

imports/ holds CSS copied out of node_modules when defineImport() points at a package rather than a URL. URLs and relative paths are referenced in place and copy nothing, so an empty folder here is the normal case. See fonts & imports.

temp/ is created on every compile and currently unused. Empty is correct.

Reading it back

The point of all that naming is that you can go from a thing on screen to the code that produced it without guessing. Build first — the output is a text file that exists in seconds, which is a faster and more honest check than starting a dev server and reasoning about what probably rendered:

Example
npx salty-css build                    # once
npx salty-css build --watch            # on change, no dev server
npx salty-css build --mode production  # what actually ships

Then work backwards.

Start at the element. In development builds every styled component carries data-component-name, taken from its export name, so [data-component-name="HeaderButton"] in the elements panel beats hunting for a hash. Production builds strip it — which is worth knowing before you write a selector that depends on it.

Take the hash to the folder. The class on the element is the hash in the filename:

Example
grep -rl "eyjPN" saltygen/css/
# saltygen/css/cl_header-button-eyjPN.css
# saltygen/css/l_0.css

Two hits is healthy: the component's own file, and the layer bundle that carries it to the browser. One hit is the diagnosis. A cl_ file with no matching entry in any l_N.css means the CSS was generated but never bundled, which is a completely different problem from a component that didn't compile.

Then pick the file that answers your actual question:

The questionThe file
Did this component compile at all?css/cl_<name>-<hash>.css
Did its CSS reach the page?css/l_<priority>.css — grep for the hash
Which layer am I in, and what beats me?The @layer line at the top of index.css
Why is this token resolving to nothing?css/_variables.css — is the custom property there?
Why doesn't this token autocomplete?types/css-tokens.d.ts
What did the compiler actually evaluate?js/, via cache/config-cache.json
Which version generated this folder?The banner in index.css

Three hashes, and they're different lengths

Reading a bundle gets confusing fast if you assume every hash is the same hash. They aren't:

LengthWhat it identifiesWhere you see it
5The style objectThe class on the element, the cl_ filename suffix
6The file pathThe /*start:*/ markers in layer bundles, imports/ prefixes
4A template patht_ and tv_ classes in _templates.css

All of them are deterministic and alphabetic-only — the same input produces the same hash on your machine, in CI, and on a colleague's laptop. Which is why a saltygen/ diff is a readable thing when you're trying to work out what a change actually did.

Templates get one extra courtesy: they're emitted twice, once readable and once hashed, so _templates.css gives you something you can actually read.

Example
.text-style-headline-large, .t_NBEr { font-size: var(--font-size-headline-large); }

Rebuilds and hot reloads write different things

There are two paths into saltygen/, and knowing which one just ran explains most "the compiler is ignoring me" moments:

Full buildOn save (HMR)
Wipes saltygen/YesNo
Runs the config passYesNo — reuses the cached config
Layer bundlesRewrittenPatched, block by block
Rewrites index.cssYesNo

Two things follow, and both are worth internalising before they cost you an hour:

Deleting a component doesn't remove its CSS. The incremental path appends and patches; it never prunes. Stale rules in the browser after a rename or a delete usually means "restart the dev server", not "the compiler is wrong."

Config changes don't take effect on save. There's no config pass in the incremental path, so _variables.css, _templates.css and css-tokens.d.ts are untouched by it. Add a token and watch nothing happen, and the fix is a full rebuild rather than a closer look at your defineVariables.

What not to do with saltygen/

Don't commit it. init adds it to .gitignore for you, and it's regenerated from scratch on every build — a prepare script running npx salty-css build is how a fresh clone gets its CSS and its types before anyone starts a dev server.

Don't hand-edit it. The next build takes the file back.

And don't build a test on its internals. The layer bundles, the per-component filenames, the js/ cache — all of it is implementation detail that can change in a minor release, and while Salty is pre-1.0 I'd much rather keep that freedom than freeze a folder layout because someone's CI grew a dependency on it. Read it while you work, quote it in a bug report, diff it when you want to know what a change did. Testing covers what is safe to assert on — the element, its attributes, the variant props, and any class you named yourself.

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