→ Chainable color transforms that resolve at build time into plain static strings.
Color Function
color() is a chainable helper for transforming colors — lighten, darken, adjust alpha, mix, rotate hue, and more. Everything runs at build time: the transformed value lands in the generated CSS as a plain static string, with no runtime cost and no color math shipped to the browser.
It's a helper like any other — a function you call to produce a value — and like every helper, it resolves while your styles compile. If you want the deeper why behind that build-time line, the Compiler concept is the read.
What it's for
The everyday use is deriving variations of a color. Give it one brand color and generate a whole family of shades — a lighter hover, a darker press, a muted disabled state, a semi-transparent overlay — instead of hand-picking each hex and hoping they stay in tune when the brand color changes. Store those derived shades as variables and the rest of your system reads clean token names while color() keeps the shades honest to their source.
One boundary matters enough to lead with, because it's the thing that trips people. color() can only transform values it can see at build time: raw colors (#0070f3, red, rgb(...)) and static token references like {colors.brand.primary}. A value that only exists at runtime — a responsive or conditional token, or anything wired through {props.X} — passes straight through unchanged, because there's no shade to compute from a value that doesn't exist yet when the compiler runs. So you derive shades from static atoms, not from themed molecules. For runtime-derived shades on a themed value, declare the shade directly under the conditional scope instead — the Theming page shows exactly that pattern.
Basic usage
Chain transforms onto a source color and hand the result to any color property:
import { styled } from "@salty-css/react/styled";
import { color } from "@salty-css/core/helpers";
export const Card = styled("div", {
base: {
backgroundColor: color("#000000").alpha(0.5), // semi-transparent black
borderColor: color("#0070f3").lighten(0.2), // a lighter brand edge
color: color("#ffffff").darken(0.1), // slightly softened white
},
});Each call resolves at build time to a finished string — rgba(0, 0, 0, 0.5) and friends — baked into saltygen/index.css. There's no color() call left in your bundle.
Deriving a palette for your variables
This is the pattern the "make variations" use is really about: define one source color, derive the family from it, and expose the shades as tokens. Change the source and the whole family moves with it.
import { defineVariables } from "@salty-css/core/factories";
import { color } from "@salty-css/core/helpers";
const brand = "#0070f3";
export default defineVariables({
colors: {
brand: {
main: brand,
light: color(brand).lighten(0.2),
lighter: color(brand).lighten(0.4),
dark: color(brand).darken(0.2),
darker: color(brand).darken(0.4),
muted: color(brand).desaturate(0.3),
overlay: color(brand).alpha(0.2),
},
},
});Components reference the derived shades by path and never touch the math:
export const Panel = styled("section", {
base: {
background: "{colors.brand.overlay}",
borderColor: "{colors.brand.dark}",
},
});One source of truth for the color, a coherent palette derived from it, and a single edit to reshade the entire set.
Theming interactive states
The other frequent use is keeping hover/press/disabled states in tune with a base color, derived right where they're used:
import { styled } from "@salty-css/react/styled";
import { color } from "@salty-css/core/helpers";
export const Button = styled("button", {
base: {
backgroundColor: "{colors.brand.primary}",
color: "#ffffff",
transition: "background-color 0.2s ease",
"&:hover": { backgroundColor: color("{colors.brand.primary}").lighten(0.1) },
"&:active": { backgroundColor: color("{colors.brand.primary}").darken(0.1) },
"&:disabled": {
backgroundColor: color("{colors.brand.primary}").desaturate(0.5).alpha(0.6),
},
},
});Remember the boundary from above: {colors.brand.primary} works here because it's a static atom. If your button's base color were a themed molecule ({theme.buttonBg}), the derive wouldn't apply — you'd declare the hover and disabled shades under conditional per mode instead. The Theming page has that worked out in full.
Reference
Color sources
color() accepts hex, rgb()/rgba(), hsl()/hsla(), named CSS colors, and static token references:
color("#ff0000");
color("#f00");
color("rgb(255, 0, 0)");
color("rgba(255, 0, 0, 0.5)");
color("hsl(210, 60%, 45%)").darken(0.1); // HSL input keeps lighten/darken predictable
color("steelblue");
color("{colors.brand.primary}"); // static token onlyMethods
All methods are chainable and return a color() instance until you output a string.
Transparency
color("#ff0000").alpha(0.5); // set absolute alpha (0–1)
color("#ff0000").fade(0.2); // reduce opacity by a relative amount
color("rgba(255, 0, 0, 0.5)").opaque(); // remove transparencyLightness
color("#ff0000").lighten(0.2); // relative, 0–1
color("#ff0000").darken(0.2); // relative, 0–1
color("#ff0000").lightness(0.8); // absolute, 0–1Saturation
color("#ff0000").saturate(0.2); // relative, 0–1
color("#ff0000").desaturate(0.2); // relative, 0–1
color("#ff0000").grayscale(); // remove all saturationHue
color("#ff0000").rotate(90); // rotate hue by degrees
color("#ff0000").hue(180); // set absolute hue, 0–360Blend
color("#ff0000").mix("#0000ff", 0.5); // mix with another color (0–1)
color("#ff0000").negate(); // complementary colorOutput formats
The default output is rgb() / rgba(). Ask for a specific format at the end of the chain:
color("rgb(255, 0, 0)").hex(); // "#ff0000"
color("#ff0000").rgb(); // "rgb(255, 0, 0)"
color("#ff0000").alpha(0.5).rgba(); // "rgba(255, 0, 0, 0.5)"
color("#ff0000").hsl(); // "hsl(0, 100%, 50%)"
color("#ff0000").alpha(0.5).hsla(); // "hsla(0, 100%, 50%, 0.5)"Color spaces and invalid input
color() parses sRGB — the same space the browser uses for #rrggbb, rgb(), hsl(), and named colors — and computes transforms in HSL internally, which is why .lighten(), .darken(), and .saturate() operate on perceptual axes rather than raw channels. Wide-gamut input (P3, oklch) is not parsed today; if you need it, ship the wide-gamut value as a raw string gated behind @supports (color(display-p3 1 1 1)).
If color() can't parse the input — a missing token path, a malformed string — it throws at build time. With defineConfig({ strict: 'warn' }) you get a warning instead and the original value passes through. Either way it surfaces in your terminal; it never reaches the browser as a silent fallback.