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 259 (blue) — the same hue as #3b82f6, which the static token build ships and which OKLCH puts at 259.8. The generated brand.500 is a step darker than the static one because it walks a fixed lightness ladder rather than being hand-picked per stop; packages/dna/test/brand-parity.test.mjs holds the two to the same hue.
| 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.
Forced colors (Windows High Contrast)
When the OS reports forced-colors: active, none of the palette above applies — the system substitutes its own colors, and it drops box-shadow altogether. That matters for focus, because a ring drawn as a box-shadow simply disappears, and a state signalled by background flattens into the system Canvas.
base.css handles this once, for every component:
@media (forced-colors: active) { /* …scoped to helix-ui elements… */ outline: 2px solid Highlight; outline-offset: 2px;}outline is the one focus affordance the mode preserves, and the system recolors it. Because the rule lives inside the media query, it changes nothing about normal rendering.
Worth knowing if you’re auditing: axe can’t see this failure mode. It measures the contrast you authored, not what the OS substitutes for it. A component can pass every automated contrast check and still be unusable in High Contrast.
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.
One colour the browser insists on
When a browser autofills a field it repaints it — pale blue in Chrome and Edge, yellow in Safari — and forces the text to near black. This is an anti-phishing signal, applied over the page’s own styling, and background-color does not take it back. On a dark theme it leaves a bright slab with black text sitting in a dark form.
helix-ui paints over it with an inset shadow (the one declaration that covers the browser’s own) and -webkit-text-fill-color, both reading from theme tokens, so a filled field matches the field beside it. You only need to do anything if a field sits on a surface other than bg.surface.default:
.checkout-panel { /* the panel is muted, so autofill should be too */ --helix-ui-autofill-bg: var(--helix-ui-color-bg-surface-muted);}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" tone="danger">Delete workspace</Button><Badge tone="brand">Beta</Badge>Both render correctly in light and dark without a single conditional — the [data-theme] attribute swaps the semantic layer underneath.