Color
helix-ui color has two levels. Ramps are numbered ladders of raw values (brand.600, neutral.200). Semantic roles are named intents (color.bg.action.brand.default, color.text.muted) that alias ramp stops. Components use roles only — the ramps exist so the roles have something consistent to point at.
Ramps
Five ramps: brand, neutral, danger, success, warning, plus standalone white and black. Status ramps only carry the stops the semantic layer actually needs, so you’ll see gaps (danger has no 200/300, for example). That’s deliberate — fewer stops, fewer wrong choices.
Brand (OKLCH-generated)
The brand ramp is the only ramp the DNA accent gene can move. It’s generated in OKLCH from a hue and a chroma multiplier: lightness steps down a fixed ladder (96% → 18%) while chroma peaks at 0.18 mid-ramp. The wildtype is hue 234 (blue), which makes brand.500 the perceptual equivalent of #3b82f6. The static token build ships the hex equivalents.
| Stop | OKLCH (DNA-derived) | Hex (shipped) | Role |
|---|---|---|---|
| 50 | oklch(96% 0.045 234) | #eff6ff | Surface tints, soft brand fills (light) |
| 100 | oklch(93% 0.0788 234) | #dbeafe | |
| 200 | oklch(87% 0.1125 234) | #bfdbfe | Brand CTA pressed (dark) |
| 300 | oklch(78% 0.1462 234) | #93c5fd | Brand CTA hover (dark), links (dark) |
| 400 | oklch(67% 0.1744 234) | #60a5fa | Dark-mode default fill, focus ring (dark) |
| 500 | oklch(56% 0.18 234) | #3b82f6 | Focus ring (light) |
| 600 | oklch(47% 0.1744 234) | #2563eb | Light-mode default fill, links (light) |
| 700 | oklch(38% 0.1462 234) | #1d4ed8 | Brand CTA hover (light) |
| 800 | oklch(28% 0.1125 234) | #1e40af | Brand CTA pressed (light) |
| 900 | oklch(18% 0.0788 234) | #1e3a8a | Soft brand fills (dark) |
The pattern to remember: light mode uses the dark half of the ramp for fills (600 → 700 → 800 as you press), dark mode uses the light half (400 → 300 → 200). Interaction always moves away from the surface.
Neutral
The workhorse ramp. Full 50–900, static (DNA doesn’t move it).
| Stop | Hex | Role |
|---|---|---|
| 50 | #f8fafc | Page surface (light) / primary text (dark) |
| 100 | #f1f5f9 | Subtle surface, secondary-button fill (light) |
| 200 | #e2e8f0 | Default border (light) |
| 300 | #cbd5e1 | Strong border (light) / secondary text (dark) |
| 400 | #94a3b8 | Muted text (dark) |
| 500 | #64748b | Muted text (light) |
| 600 | #475569 | Strong border (dark) |
| 700 | #334155 | Secondary text (light) / default border (dark) |
| 800 | #1e293b | Subtle surface (dark) |
| 900 | #0f172a | Page surface (dark) / primary text (light) |
Status ramps
| Ramp | Stops shipped | Light default | Dark default |
|---|---|---|---|
| danger | 50, 100, 400, 500, 600, 700 | 600 #dc2626 | 400 #f87171 |
| success | 50, 400, 500, 600, 700, 900 | 500 #22c55e | 400 #4ade80 |
| warning | 50, 400, 500, 600, 700, 900 | 500 #f59e0b | 400 #fbbf24 |
The 50 stops back soft fills in light mode (error banners, confirmation chips); the 900 stops (success/warning) and 700 (danger) do the same job in dark mode.
Semantic roles
This is the layer you actually write. Four groups: surface, action, text, border. Full alias tables are in the DTCG source (semantic.light.json / semantic.dark.json) and the Tokens reference; here’s how each group is meant to be used.
Surfaces — color.bg.surface.*
| Role | Light | Dark | Use for |
|---|---|---|---|
default | white | neutral.900 | The page. Cards and dialogs sit on this. |
subtle | neutral.50 | neutral.800 | Section backgrounds, table stripes, wells. |
muted | neutral.100 | neutral.700 | Disabled or recessed areas. |
inverse | neutral.900 | neutral.50 | High-contrast islands — tooltips, toasts. |
Actions — color.bg.action.<context>.<state>
Contexts: brand, neutral, danger, success, warning. States: default, hover, active, plus subtle for soft fills (badges, banners). Example: a primary button’s background is color.bg.action.brand.default, which resolves to brand.600 in light and brand.400 in dark.
Text — color.text.*
| Role | Light | Dark | Use for |
|---|---|---|---|
primary | neutral.900 | neutral.50 | Body copy, headings. |
secondary | neutral.700 | neutral.300 | Captions, helper text. |
muted | neutral.500 | neutral.400 | Placeholders, disabled labels. |
inverse | white | neutral.900 | Text on bg.surface.inverse. |
on.brand / on.danger / on.success | white | neutral.900 | Text sitting on a filled action. |
on.warning | neutral.900 | neutral.900 | Warning hue is too light for white text. |
action.brand | brand.600 | brand.300 | Links, ghost-button labels. |
Note the on.* flip: light mode puts white text on saturated fills; dark mode uses light fills (400-stops), so the text on them flips to near-black. You get this for free by using the role.
Borders — color.border.*
| Role | Light | Dark | Use for |
|---|---|---|---|
default | neutral.200 | neutral.700 | Inputs, card outlines. |
strong | neutral.300 | neutral.600 | Separators, table grids. |
focus | brand.500 | brand.400 | :focus-visible rings. Nothing else. |
danger | danger.500 | danger.400 | Invalid inputs. |
Contrast: measured, not promised
Every pair of colors helix-ui puts together is checked against WCAG AA by pnpm run check:contrast (scripts/check-contrast.mjs), which parses the built CSS variables — post-alias, post-cascade — so what’s measured is what ships. Eight advertised pairs, all passing, with real ratios from the current build:
| Pair | Ratio | Minimum |
|---|---|---|
text.primary / bg.surface.default | 17.85 | 4.5 (text) |
text.primary / bg.surface.subtle | 17.06 | 4.5 (text) |
text.secondary / bg.surface.default | 10.35 | 4.5 (text) |
text.muted / bg.surface.default | 4.76 | 3.0 (large text) |
text.on.brand / bg.action.brand.default | 5.17 | 4.5 (text) |
text.on.danger / bg.action.danger.default | 4.83 | 4.5 (text) |
border.focus / bg.surface.default | 3.68 | 3.0 (non-text) |
border.danger / bg.surface.default | 3.76 | 3.0 (non-text) |
The check exits non-zero on any failure, so a token change that breaks contrast can’t merge quietly.
Usage rules
- Never use a ramp stop in component code.
brand.600today might be the wrong stop after a theme swap;bg.action.brand.defaultnever is. - Pick roles by intent, not by appearance. A destructive button gets
dangerroles even if it currently looks like the brand color would. - Pair fills with their
oncolor. If the background isbg.action.warning.default, the text istext.on.warning— don’t hardcode white. border.focusis only for focus. Using the focus color decoratively trains users to ignore it.- Soft fills use
subtle+text.action.*, e.g. a danger banner isbg.action.danger.subtlewithtext.action.danger— not a low-opacity hack.
In practice
/* CSS — a soft warning chip */.chip-warning { background: var(--helix-ui-color-bg-action-warning-subtle); color: var(--helix-ui-color-text-action-warning); border: 1px solid var(--helix-ui-color-border-default);}// React — components resolve roles internally<Button variant="solid" color="danger">Delete workspace</Button><Badge color="brand">Beta</Badge>Both render correctly in light and dark without a single conditional — the [data-theme] attribute swaps the semantic layer underneath.