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 234 (blue), which makes brand.500 the perceptual equivalent of #3b82f6. The static token build ships the hex equivalents.

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.

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.

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.