Build a Button Whose Interaction Shades Are Computed Before .astro Pages Are Served, Not at Runtime
Derived Interaction Colors for Astro Buttons
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/astro/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 Astro 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 andcolor-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(...)againstwhite/blackfor a different curve. !importantand layers. A consumer'sstyle=will still win —color()produces ordinary stylesheet rules, not inline ones.