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

→ What's inside salty.config.ts, what can live elsewhere, and the file next to it people forget.

Configuration

npx salty-css init already wrote you a working config. This page is about what's inside it, what doesn't have to be inside it, and the small file sitting next to it that most people forget exists.

Two things are worth carrying through everything below, because they shape how a Salty project ends up looking.

Salty leans toward more files, not fewer. Your styles already had to leave your .tsx components and move into .css.ts files — that's the boundary that lets the compiler read them at build time and hand the browser a static stylesheet (the compiler concept has the long version). The config works the same way. Most of what can go in it can also live in its own file, and past a certain size it should.

Naming is your call, and most of it ships. A key you type here becomes a CSS variable name, an attribute you write in your HTML, or a string that autocompletes in every style object on the project. Salty doesn't impose a scheme. The examples across these docs are consistent about how they name things, so if you don't have strong opinions of your own yet, following that lead is a perfectly good default. There's a section on it below.

The config file

salty.config.ts lives next to your bundler config — next.config.ts, vite.config.ts, astro.config.mjs, webpack.config.js. That isn't a style preference; it's where the plugin looks.

Here's roughly what init leaves behind:

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

export const config = defineConfig({
  strict: true,
});

defineConfig takes one object and returns it unchanged. Its entire job is TypeScript: autocomplete on the keys, an error when a value is the wrong shape. A blank config is a valid config — everything has a default, and on day one the file mostly just needs to exist.

The framework subpaths (@salty-css/react/config, @salty-css/astro/config) export the same function with the same shape, so either import is fine.

The settings that only live here

Five options describe how the compiler behaves across the whole project. There's no file to move these into — this is their home.

OptionTypeWhat it does
strictboolean | 'warn'How loudly the build reacts to suspicious input — a typo'd token path, a malformed selector.
defaultUnit'px' | 'rem' | stringThe unit appended to bare numbers, so padding: 16 compiles to something real.
importStrategy'root' | 'component'One stylesheet at the app root (default), or one per component.
externalModulesstring[]Packages to leave unbundled while your .css.ts files are evaluated at build time.
reset'default' | 'none' | GlobalStylesKeep Salty's built-in CSS reset, drop it, or replace it with your own.

Two of those matter on day one.

strict is the one init turns on for you, and it's worth leaving on. Style values in Salty are permissive by design — every property accepts arbitrary strings, which is what makes modifiers and one-off escape hatches possible at all. The cost is that "{spacing.smal}" is a perfectly valid string as far as TypeScript is concerned. With strict: true, that fails the build. Without it, it quietly emits padding: {spacing.smal} into your stylesheet, the browser drops the declaration, and you spend an afternoon wondering why one card is flat. 'warn' is the middle setting if you're mid-migration and can't be strict yet.

defaultUnit decides what a bare number means. Leave it and padding: 16 is 16px; set 'rem' and the same call site becomes 1rem. It also accepts viewport-clamp:<size>, which turns bare numbers into fluid clamp() expressions against a reference width — that one's covered in Viewport clamp rather than here.

The other three rarely need touching early. importStrategy: 'root' is the default and the right answer for most apps; externalModules is for the day a browser-only package breaks build-time evaluation; reset: 'none' is for when you're bringing your own. All five, in full, live in the defineConfig reference.

The settings that don't have to live here

Four more keys are design-system content rather than compiler behaviour:

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

export const config = defineConfig({
  strict: true,

  variables: {
    colors: { brand: { main: "#0070f3" } },
    spacing: { small: "8px", medium: "16px", large: "32px" },
  },
  global: {
    body: { margin: 0 },
    a: { color: "currentcolor" },
  },
  templates: {
    textStyle: {
      body: { fontSize: "16px", lineHeight: "1.5" },
      heading: { fontSize: "32px", fontWeight: 700 },
    },
  },
});

That's a completely reasonable config for a small project, and nothing is wrong with it. variables, global, and templates each have a standalone factory that takes the exact same object — Salty merges both sources, so you can start here and move them out later without rewriting a single call site.

modifiers is the one exception: config-only, with no factory and no file it can live in. That's a real constraint with one genuine upside — when you hit an unfamiliar value shape in a codebase, there's exactly one file to open.

The define* factories

Everything else Salty can be told about your project is declared through a small set of factories. They all work the same way: call one in a file with a Salty suffix, export the result, and the compiler picks it up before it compiles anything that references it.

Minimal versions of each, with the import path that trips people up most:

defineVariables — design tokens, referenced anywhere a style takes a value. Also available as the variables key in your config. → Variables & tokens

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

export default defineVariables({
  colors: { brand: { main: "#0070f3" } },
  spacing: { small: "8px", medium: "16px", large: "32px" },
});
// use: padding: "{spacing.large}"

defineGlobalStyles — styles on bare selectors, for the handful of things that belong to the document rather than a component. Also the global config key. → Global styles

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

export default defineGlobalStyles({
  body: { margin: 0 },
  a: { color: "currentcolor" },
});

defineTemplates — a bundle of properties behind one key, so a whole text style is a single line at the call site. Also the templates config key. → Templates

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

export default defineTemplates({
  textStyle: {
    heading: { fontSize: "2.5rem", fontWeight: 700, lineHeight: 1.1 },
    body: { fontSize: "1rem", lineHeight: 1.5 },
  },
});
// use: textStyle: "heading"

defineMediaQuery — a named breakpoint. The export name is what you type as a key. → Breakpoints & responsive layouts

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

export const tabletDown = defineMediaQuery((media) => media.maxWidth(900));
// use: "@tabletDown": { padding: "{spacing.medium}" }

defineFont — registers a font as @font-face (or as an @import for a remote stylesheet) and hands back something you can drop straight into fontFamily. → Fonts

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

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

defineImport — pulls external CSS into the build. It lands in the earliest cascade layer, so imported CSS never out-muscles your own styles. → Imports

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

export default defineImport("modern-normalize/modern-normalize.css");

keyframes — no define prefix, same idea. The return value drops into the animation shorthand. → Animations

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

export const fadeIn = keyframes({
  from: { opacity: 0 },
  to: { opacity: 1 },
});
// use: animation: `${fadeIn} 300ms ease-out`

defineViewportClamp — returns a function that generates fluid clamp() values against a reference screen width, so type scales without breakpoint stair-steps. → Viewport clamp

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

export const hdClamp = defineViewportClamp({ screenSize: 1920 });
// use: fontSize: hdClamp(64)

One name that looks like it belongs on this list and doesn't: defineRuntime generates CSS when a request runs rather than when the project builds, so it goes in a plain .ts file and has nothing to do with this page.

How they get used

Nothing above is imported at the call site. Tokens, media query names, and template keys are strings the compiler resolves — and your editor autocompletes them, because the build regenerates the types from these same files:

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

export const Card = styled("section", {
  base: {
    padding: "{spacing.large}",       // from variables.css.ts
    background: "{colors.brand.main}",
    textStyle: "body",                 // from templates.css.ts
    "@tabletDown": {                   // from media.css.ts
      padding: "{spacing.medium}",
    },
  },
});

That's the whole loop: define in one file, reference by name in another, with no import chain between them.

Two rules, and no third

Because that connection is a string rather than an import, the build has to find your definitions on its own. Two things make that work, and both fail quietly when they're broken.

1. The filename suffix. Salty only compiles .css.ts, .css.tsx, .salty.ts, .styled.ts, and .styles.ts. Name it variables.ts and it type-checks perfectly, the imports resolve, and it produces exactly zero CSS.

2. A top-level export. The compiler collects exported calls. const vars = defineVariables({ … }) with no export in front of it is dead code as far as the build is concerned. (The ESLint plugin exists largely to catch this one.)

There's no third rule. No registration step, no barrel to maintain, no import to remember — the compiler walks your project, finds every file with a Salty suffix, and compiles the ones calling a define* factory first, before anything that references them.

Which means placement is genuinely free. A defineVariables call can sit in /styles/variables.css.ts, or in the same file as the one component that needs it. Tokens land on :root either way — where the file lives is about how you want to read the project, not about scope.

When something you defined doesn't show up, it's one of the two rules above or a typo in the path. An unresolved path leaves {colors.brand.main} in your CSS as literal text, which the browser drops; with strict: true it's a build error instead of a mystery.

Splitting further

A styles folder tends to settle into something like this:

Example
/salty.config.ts
/styles/variables.css.ts
/styles/themes.css.ts
/styles/media.css.ts
/styles/templates.css.ts
/styles/global.css.ts
/styles/fonts.css.ts

Nothing enforces those names or that folder — Salty finds these files by suffix and by the factory calls inside them. Split them however the project reads best: one file per factory is the common shape, but a large token set is often happier as colors.css.ts and spacing.css.ts than as one long file.

After a long day in the coal mines, inventory full, comes the part with the actual long-term satisfaction: sorting everything into a logical chest structure. Unlike the first chest you ever craft, where everything goes in together, separating it out satisfies something fairly primal. Why not give your code the same?

File structure goes further into where these files tend to live as a project grows.

Naming is your call, and it ships

Salty won't push a naming convention on you. What it does is take the names you chose and put them somewhere permanent, so it's worth knowing where each one lands before a few hundred call sites depend on it.

Token paths become CSS variables. The nested path is dashed and prefixed: colors.brand.main--colors-brand-main, fontFamily.body--font-family-body. Open DevTools on :root and you're reading the names you picked.

Conditional group names become HTML attributes. This one surprises people. Declaring conditional: { theme: { dark, light } } is what makes data-theme="dark" the attribute you write in your markup. Name the group mode instead and you write data-mode="dark"theme is a convention, not a keyword.

Media query export names become style-object keys. export const tabletDown is what makes "@tabletDown" valid. Rename the export and every call site follows. (Misspell it at a call site and nothing errors — an unknown @name compiles to a literal at-rule that simply never matches.)

Template top-level keys become properties. templates: { textStyle: { … } } is what lets you write textStyle: "heading" inside a style object, sitting right next to padding and color like it was always part of CSS.

The examples across these docs stay consistent about two habits, and they're a fine default to inherit: name things by the role they play, not by the value they holdspacing.pageMargin survives the day the margin becomes 96px, while spacing.px120 becomes a small lie you maintain forever — and keep nesting to two or three levels, because the path is what you read at every call site.

.saltyrc.json

The other file init writes, at the repo root. Unlike saltygen/, this one is meant to be committed.

/.saltyrc.json
{
  "$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/**"]
    }
  ]
}

The split between the two config files is clean: salty.config.ts tells the compiler how to build your CSS. .saltyrc.json tells the CLI where your projects are.

That's what defaultProject buys you — with it set, this works from anywhere in the repo:

Example
npx salty-css build          # instead of: npx salty-css build apps/web/src

In a monorepo you run init once per app, and each run appends its own entry to projects, so one file ends up describing the whole repo. The include / exclude globs narrow the compiler's file walk; the defaults are sensible, and they're worth revisiting only if you have a large repo and builds feel slower than they should. The $schema line is there so your editor autocompletes the file and flags typos.

Most of the time you write it once and never think about it again.

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