→ Invent a value shape, register what it becomes, and let it fire by recognition.
Modifiers
A modifier is a value shape you invent, plus a rule for what Salty should turn it into. You register a regex and a transform function in salty.config.ts, and from then on any style value matching that pattern gets rewritten while your CSS compiles — into a different value, or into that value plus a few extra declarations you'd otherwise have typed out by hand every single time.
It helps to place this next to the two neighbours it sits between. A template gives you a new key: you write textStyle: "headline.large" and a whole bundle of properties comes out the other side. A dynamic value gives you a new expression: the value is a const, an import, or a function you call right there at the call site. A modifier is the middle case — you keep the property you were always going to write (padding, color, display) and teach its value a shape Salty recognises. Nothing new to import, nothing new to remember at the call site except the syntax itself.
Put another way: a modifier is a dynamic value you decided on in advance. Same "run some TypeScript, produce a value" job, except instead of calling a function where you need it, you register the pattern once and it fires by recognition wherever it shows up. The thing that pushed me to build this was Panda CSS's color opacity modifier — write red.300/40, get a mixed color — and PostCSS functions in general. Rather than shipping a fixed set of those, Salty gives you the hook and you build the ones your project actually wants.
Everything below follows one shape: register the pattern once in salty.config.ts, then write the value anywhere a style takes one. Modifiers live only in defineConfig — there's no defineModifiers factory and no .css.ts file they can live in — which is a small constraint with one genuine upside: when you meet an unfamiliar value in a codebase, there's exactly one file to open.
import { defineConfig } from "@salty-css/core/config";
export const config = defineConfig({
strict: true,
modifiers: {
// modifiers go here
},
});And like everything else in Salty, this is build-time work. The transform runs while the stylesheet compiles, and what lands in saltygen/index.css is ordinary CSS. None of it reaches the browser — no runtime, no lookup table, no trace of the syntax you invented. (If you want the longer version of that story, the Compiler concept covers it.)
How a modifier works
Two fields: a pattern to match and a transform to run.
modifiers: {
space: {
pattern: /\bspace:(-?[\d.]+)\b/g,
transform: (match) => ({ value: "…" }),
},
},Three facts decide whether your pattern actually fires, and the first one surprises everyone.
The pattern is tested against the finished declaration, not the bare value. By the time modifiers run, Salty has already assembled the property name, the colon, the value and the semicolon into one string — and that's the string your regex sees:
padding: "space:4" // → "padding:space:4;"
color: "{colors.brand.main}" // → "color:var(--colors-brand-main);"
padding: 16 // → "padding:16px;"So a pattern anchored around the value alone — /^space:\d+$/ — never matches, because there's a padding: in front of it and a ; behind. Use /\bspace:\d+\b/ instead. The upside of the same fact is that you can deliberately scope a modifier to one property by putting the property name in the pattern, which the flex-center and line-clamp examples below both do.
Tokens are resolved before modifiers run. {colors.brand.main} has already become var(--colors-brand-main) by the time your regex is tested, so match against the var() form, not the curly braces. That ordering isn't an accident and it shapes what a color modifier can do — the color example below is entirely about it.
transform receives the whole matched string, not the capture groups. Groups are still useful for making the pattern precise, but inside the function you re-parse the string yourself. Return an object with value (required — it replaces the matched substring) and optionally css, an object of extra declarations emitted alongside.
One more thing worth knowing before you have three of these: modifiers run in the order you declare them in the config, and each one sees the string the previous one produced. That's occasionally useful — one modifier can feed another — and occasionally the reason a pattern mysteriously stops matching, because something broader upstream already ate it.
Examples
A spacing scale that does the arithmetic
Design systems drift. One engineer writes padding: "14px", someone else writes 0.875rem, and the 4px grid a designer carefully built quietly stops being a grid. The fix is to make the scale the easiest thing to type.
Define it once — the pattern is deliberately loose about what it matches, and the transform is strict about what it accepts:
import { defineConfig } from "@salty-css/core/config";
export const config = defineConfig({
strict: true,
modifiers: {
space: {
pattern: /\bspace:(-?[\d.]+)\b/g,
transform: (match) => {
const steps = Number(match.replace("space:", ""));
if (!Number.isInteger(steps)) {
throw new Error(`Off-scale spacing: "${match}" — spacing steps are whole numbers.`);
}
return { value: `${steps * 4}px` };
},
},
},
});Then use it anywhere a length goes:
import { styled } from "@salty-css/react/styled";
export const Card = styled("article", {
base: {
padding: "space:4", // → 16px
gap: "space:2", // → 8px
margin: "space:2 space:4", // → 8px 16px
},
});Roughly, this is what compiles:
.aBdKm {
padding: 16px;
gap: 8px;
margin: 8px 16px;
}The third line is the one to notice. Because pattern carries the g flag, the rewrite visits every match in the declaration, so a shorthand with two, three or four values is two, three or four rewrites. Drop the flag and only the first one is transformed — which is a fun ten minutes to debug, so don't drop the flag.
The if is the other half of the point. A transform that throws fails the build with your message attached, which turns "please stay on the scale" from a code-review conversation into a compiler error. That's why the pattern accepts [\d.]+ rather than just digits: if it only matched integers, space:3.5 would quietly match the space:3 part, leave the .5 stranded, and emit padding: 12px.5. Matching loosely and validating in the transform means the mistake gets caught instead of getting weird.
Now the honest comparison, because a modifier isn't automatically the right call here. Tokens are the better default for named values — {spacing.medium} autocompletes, gets validated against real token paths, and shows up in your editor without anyone reading the config. A modifier gives up all of that: space:4 is an opaque string as far as TypeScript is concerned, and a typo like spcae:4 compiles straight through as an invalid CSS value. What you get in exchange is arithmetic. A twenty-step scale is twenty token entries to write and maintain, and the twenty-first request means editing the config again; as a modifier it's one line and every step that will ever exist. Somewhere around "the scale is a formula, not a vocabulary" is where I'd switch.
One value, several declarations: display: "flex-center"
Centering is three declarations that always travel together, and at least one of align-items / justify-content gets typed the wrong way round on a regular basis. This is where the css return value earns its keep: a modifier doesn't only rewrite the value it matched, it can bring extra declarations along.
modifiers: {
flexCenter: {
pattern: /\bflex-center\b/,
transform: () => ({
value: "flex",
css: { alignItems: "center", justifyContent: "center" },
}),
},
},import { styled } from "@salty-css/react/styled";
export const Center = styled("div", {
base: {
display: "flex-center",
minHeight: "100dvh",
},
});.KqdFp {
align-items: center;
justify-content: center;
display: flex;
min-height: 100dvh;
}The extra declarations land in the same rule and the same cascade layer as the one that triggered them, immediately before it. That ordering has one practical consequence: within a single rule the last declaration for a property wins, so anything you write after display: "flex-center" overrides the modifier, and anything you write before it loses. In practice this is what you want — display: "flex-center" followed by alignItems: "flex-start" reads like an override and behaves like one — but it's worth knowing which way round it goes.
Two caveats, one small and one worth taking seriously. The small one: flex-center isn't a real display value, and TypeScript is fine with that — Salty's style types accept arbitrary strings on every property, so this compiles. The serious one is the flip side of the same coin. Nothing checks your invented syntax. Write flex-centre and you'll ship display: flex-centre, the browser will discard the declaration, and nothing anywhere will have complained. Modifiers move a class of typos out of the type system's reach, and that's the real cost of the whole feature.
You'll also notice /\bflex-center\b/ matches that string in any property's value, which is fine for a syntax this distinctive but sloppy in principle. Since the pattern sees the whole declaration, you can pin it down:
pattern: /(?<=^display:)flex-center(?=;$)/,That reads as: the entire value of a display declaration, and nothing else. Any other property using the word flex-center is left alone. Reach for this whenever your syntax is generic enough that it might collide with something — and always when the value is a plain English word.
A color with an opacity suffix
The Panda-flavoured one, and the one that's actually in use in my own projects. You want {colors.black}/60 to mean "that color at 60%", anywhere a color goes.
modifiers: {
colorOpacity: {
pattern: /var\(--[a-z0-9-]+\)\/\d{1,3}\b/gi,
transform: (match) => {
const [color, percentage] = match.split("/");
return { value: `color-mix(in srgb, ${color} ${percentage}%, transparent)` };
},
},
},import { styled } from "@salty-css/react/styled";
export const Overlay = styled("div", {
base: {
background: "{colors.black}/60",
borderColor: "{colors.brand.main}/20",
"&:hover": { background: "{colors.black}/80" },
},
});.mRxEv {
background: color-mix(in srgb, var(--colors-black) 60%, transparent);
border-color: color-mix(in srgb, var(--colors-brand-main) 20%, transparent);
}
.mRxEv:hover {
background: color-mix(in srgb, var(--colors-black) 80%, transparent);
}The pattern matches var(--…) rather than {colors.black} because of the ordering mentioned earlier: token substitution runs first, so by the time your regex is tested the token reference is already a CSS custom property reference. Match the shape that actually arrives, not the shape you typed.
That ordering is also the reason this modifier produces color-mix() instead of a precomputed color. A token isn't a color at build time — it's a pointer to one, and its value doesn't exist until the browser resolves the custom property. Nothing running at compile time can look inside it, which is the same boundary the built-in color() helper runs into: hand it a themed token and it passes straight through untouched, because there's nothing there yet to lighten. color-mix() sidesteps the problem by doing the math in the one place the variable is real. It costs nothing at runtime beyond what any CSS value costs, it's Baseline widely available and has been in every major browser since 2023, and — the part that makes it worth having — it follows the token. Flip your theme and every mixed color flips with it, because the var() inside the mix resolves to whatever the theme says now.
One note on the color space: in srgb is what I'd default to for fading toward transparent, since it's the one that behaves the way most people picture it. Swap in oklab or anything else if you have a reason.
And a fallback is worth adding, even though the support table says you don't need one. Browser stats are a tech-bubble number — the odd browser, the locked-down work laptop, the embedded webview nobody has updated since 2021 turn up in real traffic later than you'd expect, and a background that silently resolves to nothing is a bad way to find out. The reliable pattern is the old one: emit the solid color first, then the mix. A browser that can't parse color-mix() drops the second declaration and keeps the first.
To do that the modifier needs to know which property it's on, so scope the pattern to the whole declaration and rebuild both halves in the transform — remember, value replaces the entire match, and there's nothing stopping it from being two declarations:
modifiers: {
colorOpacity: {
pattern: /^[a-z-]+:var\(--[a-z0-9-]+\)\/\d{1,3};$/i,
transform: (match) => {
const [property, value] = match.replace(/;$/, "").split(/:(.+)/);
const [color, percentage] = value.split("/");
return {
value: `${property}:${color};${property}:color-mix(in srgb, ${color} ${percentage}%, transparent);`,
};
},
},
},.mRxEv {
background: var(--colors-black);
background: color-mix(in srgb, var(--colors-black) 60%, transparent);
}Same call sites, one extra declaration each, and the old browser gets a solid color instead of nothing.
Line clamp — hiding a compat workaround
Truncating text to a fixed number of lines is, still, four declarations that only work as a set: display: -webkit-box, -webkit-box-orient: vertical, -webkit-line-clamp: N, and overflow: hidden. Miss one and the whole thing is a no-op. The standardised line-clamp property exists and is not Baseline yet, so the prefixed incantation is still the interoperable path.
That combination — a fixed group of declarations, one number that varies, and a compat story that will change later — is exactly what modifiers are good at.
modifiers: {
lineClamp: {
pattern: /(?<=^overflow:)lines:\d+(?=;$)/,
transform: (match) => ({
value: "hidden",
css: {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: Number(match.replace("lines:", "")),
},
}),
},
},import { styled } from "@salty-css/react/styled";
export const Excerpt = styled("p", {
base: {
overflow: "lines:3",
},
});.wZtQr {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}overflow hosts the syntax because it's the one declaration in the group whose value is genuinely its own — hidden is a real answer to "what should overflow do here" — while the other three are pure ceremony. The lookbehind keeps lines:3 from meaning anything on any other property. And when the unprefixed line-clamp finally becomes safe to use, the migration is four lines in one config file rather than a search across every component that truncates text.
That last sentence is really the argument for this whole category. A modifier is a good home for knowledge that is true today and won't be forever: vendor prefixes, compat workarounds, the specific incantation a browser bug demands. Templates and tokens are for your design decisions; modifiers are a decent place for the platform's current mood.
One organisational note while you're here: modifiers can only be registered in defineConfig, but the config file is ordinary TypeScript, so they don't have to be written there. Once you have more than a couple, move them into a plain .ts module — typed as CssModifiers, imported from @salty-css/core/config — and spread the object into modifiers. No Salty filename suffix needed, since that file is data rather than styles. Spread order is run order, so the config still reads as the list of what fires and in what sequence.
Which tool for which job
Four features in Salty overlap around "reuse this thing," and the difference between them is what you're actually reusing:
| You want to reuse… | Reach for |
|---|---|
| A named value, shared and typed across the system | defineVariables tokens |
| A bundle of properties that always travel together, under one key | defineTemplates |
| A computation you call where you need it | a helper or a dynamic value |
| A value shape recognised anywhere you write it | a modifier |
The tie-breaker I'd use: if you can name the thing, it's a token or a template. If it's a syntax — something with a variable part that would be a hundred names if you tried to enumerate it — it's a modifier.
Gotchas
Patterns get greedy. Your regex sees the whole declaration, property name included, so a loose pattern has more to chew on than you think — /\d+u\b/ will happily find a match inside a font name or a url(). Keep patterns specific, and scope them to a property with a lookbehind whenever the syntax is anything close to plain English.
Keep the css return flat. It's for extra declarations. Nested selectors and at-rules inside that object aren't scoped the way you'd expect, so put those on the component where they belong.
Order is declaration order. Modifiers run in the sequence they appear in the config, each on the previous one's output. Chaining is fine and occasionally handy; a broad pattern sitting first and eating everything is not.
Every modifier is a word your team has to learn. No autocomplete, no red squiggle on a typo, and a newcomer has to find salty.config.ts to discover what space:4 means. That's a fair trade for a syntax used in three hundred places and a bad one for a syntax used in three. If you're not sure, a token or a template is the safer default — those are more discoverable by design, and you can always promote the pattern to a modifier once it's genuinely everywhere.
Coming soon — the recipes below put modifiers to work in real projects.