@cssints/ramp

OKLCH colour ramps for cssints, computed at build time. ramp(base, options) returns plain DTCG data (a colour group, steps "1" to "N", each a hex colour in sRGB) that you pass to createTokens yourself, and overrides(ramp) turns a ramp into the override object of createGlobalTheme. The package has no runtime and no scope(): like the sets of @cssints/tokens, a ramp is data, so it goes under any group name, sits beside other tokens in one call, and its steps get @property rules, themes and check.contrast from cssints.

// tokens.ts
import { createGlobalTheme, createTokens, media } from "cssints" with { type: "cssints" };
import { overrides, ramp } from "@cssints/ramp";

const ratios = [1.2, 1.5, 3, 4.5, 7] as const;

export const t = createTokens({
  bg: { $type: "color", $value: "#ffffff" },
  blue: ramp("#2563eb", { mode: "contrast", ratios, background: "#ffffff" }),
  gray: ramp("#6b7280", { mode: "lightness", steps: 9 }),
});

// the same ratios on the dark background, under the same condition as it
createGlobalTheme(t, media("(prefers-color-scheme: dark)"), {
  bg: "#111111",
  blue: overrides(ramp("#2563eb", { mode: "contrast", ratios, background: "#111111" })),
});
// text.ts
import { bg, check, cn, color } from "cssints" with { type: "cssints" };

import { t } from "./tokens.ts";

check.contrast(t.blue._3, t.bg, { min: 3 }); // borders, large text
check.contrast(t.blue._4, t.bg, { min: 4.5 }); // body text, light and dark
export const text = cn(color(t.blue._4), bg(t.bg));

The sheet of text.ts:

@property --bg {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffffff;
}
@property --blue-4 {
  syntax: "<color>";
  inherits: true;
  initial-value: #356fec;
}
@layer _.k {
  @media (prefers-color-scheme: dark) {
    :root {
      --bg: #111111;
      --blue-1: #041e59;
      --blue-2: #092d7c;
      --blue-3: #1855db;
      --blue-4: #3d76ed;
      --blue-5: #719df2;
    }
  }
}
@layer _.a {
  ._1od4w7v {
    color: var(--blue-4);
  }
  ._q77new {
    background-color: var(--bg);
  }
}

Modes

Every step keeps the base's OKLCH hue and its chroma relative to the sRGB gamut (nutelch's idea): the base's chroma over the most chroma sRGB holds at the base's lightness and hue. A step at another lightness takes the same share of the most sRGB holds there, so a vivid base stays vivid near its hue's cusp, thins out towards white and black, and every step is in sRGB by construction (the hex is the 8-bit rounding of an in-gamut colour, never a clipped one). A grey base gives a neutral ramp. The base itself need not be a step.

The numbers are deterministic: the same base and options give the same hex on every machine (no randomness, a fixed number of bisection steps).

Contrast

What a contrast ramp promises, exactly:

Limits

cd packages/ramp && bun run test        # the ramp maths, then a fixture through the engine (check.contrast passes, a tight pair fails)
cd packages/ramp && bun run typecheck   # the type test (test/types/ramp.ts)

Credits

Two ideas, written here: the chroma relative to the sRGB gamut from nutelch (MIT, David Aerne), and the search for a lightness that meets a contrast ratio from Leonardo (Apache-2.0, Adobe). The colour maths is culori (MIT, Dan Burzo), a dependency. No code is copied.