Skip to content

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.

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.

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.

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)
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.

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.

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>

Section titled “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.

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.

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.

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.

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.

  1. Never use a ramp stop in component code. brand.600 today might be the wrong stop after a theme swap; bg.action.brand.default never is.
  2. Pick roles by intent, not by appearance. A destructive button gets danger roles even if it currently looks like the brand color would.
  3. Pair fills with their on color. If the background is bg.action.warning.default, the text is text.on.warning — don’t hardcode white.
  4. border.focus is only for focus. Using the focus color decoratively trains users to ignore it.
  5. Soft fills use subtle + text.action.*, e.g. a danger banner is bg.action.danger.subtle with text.action.danger — not a low-opacity hack.

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);
}
/* 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.