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.

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.

StopOKLCH (DNA-derived)Hex (shipped)Role
50oklch(96% 0.045 234)#eff6ffSurface tints, soft brand fills (light)
100oklch(93% 0.0788 234)#dbeafe
200oklch(87% 0.1125 234)#bfdbfeBrand CTA pressed (dark)
300oklch(78% 0.1462 234)#93c5fdBrand CTA hover (dark), links (dark)
400oklch(67% 0.1744 234)#60a5faDark-mode default fill, focus ring (dark)
500oklch(56% 0.18 234)#3b82f6Focus ring (light)
600oklch(47% 0.1744 234)#2563ebLight-mode default fill, links (light)
700oklch(38% 0.1462 234)#1d4ed8Brand CTA hover (light)
800oklch(28% 0.1125 234)#1e40afBrand CTA pressed (light)
900oklch(18% 0.0788 234)#1e3a8aSoft 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).

StopHexRole
50#f8fafcPage surface (light) / primary text (dark)
100#f1f5f9Subtle surface, secondary-button fill (light)
200#e2e8f0Default border (light)
300#cbd5e1Strong border (light) / secondary text (dark)
400#94a3b8Muted text (dark)
500#64748bMuted text (light)
600#475569Strong border (dark)
700#334155Secondary text (light) / default border (dark)
800#1e293bSubtle surface (dark)
900#0f172aPage surface (dark) / primary text (light)

Status ramps

RampStops shippedLight defaultDark default
danger50, 100, 400, 500, 600, 700600 #dc2626400 #f87171
success50, 400, 500, 600, 700, 900500 #22c55e400 #4ade80
warning50, 400, 500, 600, 700, 900500 #f59e0b400 #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.*

RoleLightDarkUse for
defaultwhiteneutral.900The page. Cards and dialogs sit on this.
subtleneutral.50neutral.800Section backgrounds, table stripes, wells.
mutedneutral.100neutral.700Disabled or recessed areas.
inverseneutral.900neutral.50High-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.*

RoleLightDarkUse for
primaryneutral.900neutral.50Body copy, headings.
secondaryneutral.700neutral.300Captions, helper text.
mutedneutral.500neutral.400Placeholders, disabled labels.
inversewhiteneutral.900Text on bg.surface.inverse.
on.brand / on.danger / on.successwhiteneutral.900Text sitting on a filled action.
on.warningneutral.900neutral.900Warning hue is too light for white text.
action.brandbrand.600brand.300Links, 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.*

RoleLightDarkUse for
defaultneutral.200neutral.700Inputs, card outlines.
strongneutral.300neutral.600Separators, table grids.
focusbrand.500brand.400:focus-visible rings. Nothing else.
dangerdanger.500danger.400Invalid 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:

PairRatioMinimum
text.primary / bg.surface.default17.854.5 (text)
text.primary / bg.surface.subtle17.064.5 (text)
text.secondary / bg.surface.default10.354.5 (text)
text.muted / bg.surface.default4.763.0 (large text)
text.on.brand / bg.action.brand.default5.174.5 (text)
text.on.danger / bg.action.danger.default4.834.5 (text)
border.focus / bg.surface.default3.683.0 (non-text)
border.danger / bg.surface.default3.763.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

  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.

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.