Version 0.1.0 just released! Check out the release notes from GitHub Releases

Generate Button Interaction Shades From One Brand Color, Computed Before the React Bundle Is Built

Color-Derived Button Shades in React

Hover and active states usually start as "make it a bit darker." Then a designer changes the brand color, and you remember the six places you eyeballed the shade. Salty CSS's color() helper is the right tool: pass one color in, derive .lighten() / .darken() / .alpha() shades from it. The transformation runs at build time, so the generated CSS is a static string — no client-side color math, no bundle bloat.

The component

// /components/button.css.ts
import { styled } from "@salty-css/react/styled";
import { color } from "@salty-css/core/helpers";

const brand = "#0070f3";

export const Button = styled("button", {
  base: {
    background: brand,
    color: "white",
    border: "1px solid transparent",
    borderRadius: "0.375rem",
    padding: "0.5em 1em",
    cursor: "pointer",
    transition: "background 0.15s ease, transform 0.05s ease",

    "&:hover": {
      // 10% lighter on hover.
      background: color(brand).lighten(0.1),
      borderColor: color(brand).darken(0.15),
    },

    "&:active": {
      // 10% darker, slight nudge.
      background: color(brand).darken(0.1),
      transform: "translateY(1px)",
    },

    "&:disabled": {
      // Desaturated and translucent — clearly inactive.
      background: color(brand).desaturate(0.5).alpha(0.6),
      cursor: "not-allowed",
    },
  },
});

Or derive from a theme token

color() parses static token references too, so you can keep the source of truth in defineVariables:

import { color } from "@salty-css/core/helpers";

export const PrimaryButton = styled("button", {
  base: {
    background: "{colors.brand.primary}",
    color: "white",

    "&:hover": {
      background: color("{colors.brand.primary}").lighten(0.1),
    },
    "&:active": {
      background: color("{colors.brand.primary}").darken(0.1),
    },
  },
});

The token has to be static — i.e. defined once in defineVariables without a conditional branch. The build can only transform a value it knows about. For shades on a themed token that changes between dark/light, declare the shade variants on the conditional variable itself; see the Color Function note on when transformations happen.

What it produces

After the build, color(brand).lighten(0.1) is a plain rgb(…) string in saltygen/index.css. The color() import is tree-shaken out of the React component entirely. The browser sees a static stylesheet; the only "computation" left to it is matching :hover and :active.

Gotchas

  • No runtime values. Don't pass color({props.bg}) — there's no way to derive a shade from a value that doesn't exist yet at build time. Use a CSS custom property and color-mix() if you need true runtime derivation.
  • HSL space. .lighten() / .darken() operate on HSL lightness, which is usually what you want but can produce surprising results on highly-saturated source colors. Adjust the multiplier, or switch to a .mix(...) against white / black for a different curve.
  • !important and layers. A consumer's style= will still win — color() produces ordinary stylesheet rules, not inline ones.