A Storybook audit of the physical color tokens generated by @ch-ui/tokens, showing the neutral and accent palettes rendered as continuous curves.

A color system that knows what it means

Before I’d created the design system for Composer, I’d decided to pair Radix UI with Tailwind, and that meant working with Tailwind’s color palette model: a set of numbered shades per hue, gray-100 through gray-900 and so on. This worked well enough at first, but it started to break down as we added dark mode, as we wanted to support wide-gamut displays, and as we found ourselves eyeballing hex values for contrast when we should have been able to compute them.

Over time I built @ch-ui/tokens to address these problems at their root. Instead of hand-curated swatches, it generates all color values from curves drawn through perceptual color space, then maps meaningful names to positions on those curves. This is the approach, and how it evolved.

Curves through OK Lab

The core idea is that a palette’s true form isn’t a list of hex values but a continuous curve through a perceptual color model. @ch-ui/tokens defines palettes as helically transformed Bézier curves through OK Lab color space, parameterized by a key color and a pair of control points. You define the hue, chroma, and lightness that best characterize the color, and two control points that shape how the curve transitions from black through that key color to white. Every value along the curve is computed from there.

Because the curve exists in a perceptual space, equal steps along it correspond to perceptually equal changes in lightness. That’s something you can’t get from a list of handpicked swatches, no matter how carefully they were chosen.

The mustard problem

Procedurally generated palettes run into a well-known issue: as you decrease the lightness of certain hues, the results pass through an unappealing zone. A warm yellow becomes a muddy mustard before it becomes a rich brown. The palette’s dark end looks wrong even though its light end looked fine.

The fix was to apply torsion along the hue axis of the Bézier curves. This lets a palette gently twist its hue as it gets darker — a warm yellow can rotate toward red in its darkest shades, yielding a deep amber instead of mustard. The twist is smooth and continuous, governed by the same Bézier parameterization, so it doesn’t introduce abrupt transitions.

Physical, semantic, alias

The token system has three layers. The physical layer defines the raw palette curves and renders them as CSS custom properties at three gamut levels — sRGB, Display P3, and Rec. 2020 — each guarded by the appropriate @media (color-gamut: ...) condition. If the display supports P3, the browser uses P3 values automatically; otherwise it uses sRGB.

The semantic layer maps meaningful names to physical values per condition. I borrowed the term sememes from linguistics — they’re the smallest units of meaning. A sememe like bg-base resolves to neutral at position 975 in light mode and position 150 in dark mode, where the position indexes into the physical curve. Both positions are on the same continuous curve, so the relationship between light and dark is principled rather than ad-hoc.

The alias layer gives sememes shorter, more convenient names for use in component styles — bg-base becomes baseBg, for instance. A thin mapping, but it decouples the token system’s internal naming from the names developers actually type.

Surface color tokens in Composer. The same semantic tokens produce appropriate dark and light values without any per-mode overrides in component code.

Wide gamut for free

Most design systems still define all their colors in sRGB. If you’re on a P3-capable display, you never see colors as vivid as your hardware could produce. @ch-ui/tokens renders each physical curve three times — once per gamut — and lets the CSS cascade select the best representation. The semantic layer is indifferent to gamut; it references curve positions, not specific color values. This means P3 and Rec. 2020 support falls out of the architecture without any extra mapping work.

Computing contrast

One feature I’m especially pleased with: the semantic layer supports expressions, operations on referenced values. The most useful of these computes an APCA contrast target against a named token. A sememe defined as ['neutral', '88f:bg-base'] means “the point on the neutral curve that achieves an APCA Lc of ±88 against the value of bg-base”. You can define foreground colors in terms of their contrast against the backgrounds they’ll sit on.

This is especially useful for theming: when the background shifts between light and dark mode, the foreground’s curve position shifts automatically to maintain the target contrast. The relationship between text and background is preserved by definition.

Composer after the neutral cadence was tuned to align with broader ecosystem conventions. The same token definitions produce both themes.

The long road here

None of this was built in one pass. Composer’s color tokens went through a long evolution, starting with hand-curated Tailwind palettes where adjustments meant manually tuning individual hex values. Workable, but fragile — a single palette change could mean touching dozens of downstream tokens.

Adopting @ch-ui/tokens replaced this with a parameterized system where changing the key color or control point of a curve cascades cleanly through every token that references it. I also added an audit story in Storybook that renders the full physical palette and highlights which values are actually consumed by semantic tokens, which made it much easier to identify unused or misaligned values.

Sememe-based tokens applied to forms. Adjustments to the system's sememes propagate uniformly across every component.

Later refinements bifurcated the surface token cadence into separate elevation and contrast cadences, replaced numbered tokens like surface-01 with hypernymic names describing each surface’s semantic role, and aligned the neutral cadence with broader ecosystem patterns. Each of these changes was a few lines of curve parameters or sememe mappings that propagated predictably through every component.

The system now also supports CSS cascade layers, namespaced token prefixes, and user color scheme overrides. I’ve extracted it into @ch-ui/tokens for use in other projects, including this site.