Skip to content

Color themes

The data-color attribute on <html> (or any subtree) swaps the accent palette — focus ring + every primary action button / checked checkbox / current pagination item — without re-rendering markup. Five themes ship out of the box; each accent shade has been picked to clear WCAG AA contrast in both light and dark mode, so the same attribute works regardless of the active data-theme.

data-color changes only the accent — the primary action colour and focus ring. It is orthogonal to data-neutral (the surface / text / border ramp), data-theme (light / dark), and data-density (comfortable / compact / dense). Set them on the same ancestor and they all cascade together — e.g. data-color="indigo" data-neutral="slate" is an indigo accent on a cool slate UI. To change the greys, reach for data-neutral; this page is just the accent.

Each built-in theme is accent-only — its palette is the primary / hover / soft-tint / text-on-primary / focus-ring set below (the soft swatch is semi-transparent, shown over a checkerboard):

default
primary
hover
soft
text
ring
indigo
primary
hover
soft
text
ring
emerald
primary
hover
soft
text
ring
rose
primary
hover
soft
text
ring
amber
primary
hover
soft
text
ring
data-color valuePrimary bgHover bgText on primaryCommon use case
(none) / default#2563eb (blue.600)#1d4ed8 (blue.700)whiteDefault neutral SaaS — Linear, AWS-style
indigo#4f46e5 (indigo.600)#4338ca (indigo.700)whiteModern SaaS — Tailwind, Cal.com
emerald#047857 (green.700)#065f46 (green.800)whiteFintech / sustainability — Stripe Atlas
rose#be123c (rose.700)#9f1239 (rose.800)whiteMarketing / CRM / healthcare
amber#f59e0b (amber.500)#d97706 (amber.600)gray.900Energetic / productivity — Notion-yellow

Every value above resolves to a primitive ramp shade — these themes don’t introduce new colors, they re-point the accent at a different ramp.

Two shades land deeper on the color scale (emerald.700, rose.700) so white text on the button clears the 4.5:1 contrast bar that the brighter 600 shades would have missed. Amber is the inverse — the 500 shade is bright enough that dark text becomes the contrast-clean choice, and it stays consistent in dark mode too.

Each row below renders the same button + checkbox + radio + alert inside a wrapper carrying a different data-color value. Toggle the Theme button in Starlight’s header to flip the whole row to dark mode and confirm every theme stays legible on the dark surface.

Each row exercises the full set of theme-aware surfaces: a primary button, a ghost button (hover it to see the soft tint), an input (focus it to see the themed border + ring), a checked checkbox, and a checked radio. Selecting the helper text inside any row shows the themed ::selection highlight.

Select this text to preview ::selection.
Select this text to preview ::selection.
Select this text to preview ::selection.
Select this text to preview ::selection.
Select this text to preview ::selection.

For a site-wide theme, set the attribute on <html>:

<html data-color="emerald">
...
</html>

For a per-section override (e.g. a marketing landing surface inside an otherwise neutral app), attach it locally:

<section data-color="rose">
<button class="hc-button" data-variant="primary">Subscribe</button>
</section>

The attribute changes only the accent palette. It does not touch the surface / background / text colors — those are owned by data-theme (light / dark).

Light / dark toggle — installThemeToggle()

Section titled “Light / dark toggle — installThemeToggle()”

data-theme is yours to set — server-side, or with the bundled installThemeToggle() behavior (in the auto-init /behaviors entry):

<button type="button" class="hc-button" data-variant="ghost"
data-hc-theme-toggle data-persist="hc-theme">
<span aria-hidden="true"></span>
</button>
  • The effective theme is data-theme on <html> when present, otherwise the OS preference (prefers-color-scheme). Clicking flips it and writes data-theme="light|dark" explicitly.

  • data-persist="<key>" (optional) stores the choice in localStorage and restores it on install. For a flash-free restore, also inline this in <head> before the stylesheet (the behavior alone runs after first paint):

    <script>
    try {
    var t = localStorage.getItem('hc-theme');
    if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
    } catch (e) {}
    </script>
  • The toggle reflects state via aria-pressed ("true" = dark) and — when icon-only — gets a default aria-label from the i18n catalog (themeToggle.label; an authored aria-label wins). Each change dispatches a bubbling hc:themechange event (detail.theme).

  • A page that hardcodes data-theme="dark" keeps working unchanged — the toggle simply starts from that explicit value.

These tokens are overridden per data-color value (under color.{name}.tokens.json):

--hc-color-focus-ring
--hc-color-action-primary-bg
--hc-color-action-primary-fg
--hc-color-action-primary-border
--hc-color-action-primary-hover-bg
--hc-color-action-primary-hover-border
--hc-color-action-primary-soft-bg

primary-soft-bg is a 12 % (18 % for amber) tint of the theme’s primary color generated with color-mix() and transparent, so the same value blends correctly on both light and dark surfaces — no per-mode variant required.

Components consume these through their own --hc-{component}-* variables (with var() indirection, the same pattern used for density), so the cascade flows from data-color--hc-color-action-primary-*--hc-button-primary-* / --hc-input-focus-border / --hc-checkbox-checked-* / etc.

Components and surfaces affected by the cascade:

  • Buttondata-variant="primary" background and hover state.
  • Buttondata-variant="ghost" hover background now picks up the theme tint instead of a neutral grey.
  • Input — the :focus-visible border color follows the theme’s focus-ring shade.
  • Checkbox:checked fill (when no per-variant override).
  • Radio:checked fill.
  • Pagination — the aria-current="page" cell.
  • Every component’s :focus-visible ring — driven directly by --hc-color-focus-ring.
  • ::selection — text-selection highlight uses primary-soft-bg, so highlighting any prose on the page becomes a low-key brand cue.

Components and tokens deliberately not affected:

  • data-variant="secondary" (on hc-button) — neutral grey by design. Keeps primary visually distinct as the brand-themed action, while secondary reads as a second-tier neutral CTA in every theme.
  • data-variant="error" / data-variant="success" — semantic meaning, must stay red / green regardless of accent.
  • Semantic colors (info, success, warning, error) — informational palette, fixed.
  • --hc-color-muted-bg — generic muted surface bg, swaps only between light and dark, never between color themes.
  • Surface / background / text colors — owned by data-theme (light / dark).
  • Elevation shadows (--hc-shadow-sm / -md / -lg / -overlay, plus the --hc-shadow-edge scroll-hint color) — owned by data-theme: the dark steps carry stronger alphas so dropdowns, dialogs, and drawers stay legible on dark surfaces. A full custom theme can override the five steps like any other token.
  • Container padding / gap — owned by data-density.

The five built-in themes are not special — each is just a small set of accent tokens. Adding your own brand palette takes one of two paths, depending on whether you want to ship the theme as part of a build or drop it onto a live page without one.

How the cascade actually works (important)

Section titled “How the cascade actually works (important)”

A color theme is not just the seven --hc-color-action-primary-* semantic variables. Components don’t read those directly — each reads its own --hc-{component}-* variable (--hc-button-primary-bg, --hc-checkbox-checked-bg, --hc-pagination-current-bg, …), and the build bakes a concrete value into each one per theme block. That is why every [data-color] block in hc.tokens.css redeclares ~50 component variables, not 7.

So overriding only the semantic variables at runtime does not recolor buttons, checkboxes, etc. (a custom-property reference is resolved on the element that declares it and then inherited frozen, so a nested [data-color] wrapper can’t re-resolve it). A correct runtime override has to redeclare the component variables — the full block. Generate that block with the Theme builder rather than writing it by hand.

Path A — paste a generated CSS block (no rebuild)

Section titled “Path A — paste a generated CSS block (no rebuild)”

Use the Theme builderTheme CSS block. Add it to any stylesheet loaded after @hypermedia-components/core/css, then set the attribute. No build step. It works site-wide or on a subtree:

<html data-color="brand"></html>
<!-- or scope it to one section -->
<section data-color="brand"></section>

The generated block looks like a built-in theme block — every affected component variable, resolved to your color:

@layer hc.tokens {
[data-color="brand"] {
--hc-color-action-primary-bg: #7c3aed;
--hc-button-primary-bg: #7c3aed;
--hc-checkbox-checked-bg: #7c3aed;
--hc-pagination-current-bg: #7c3aed;
/* …radio, tabs, slider, calendar, focus ring, ::selection tint, … */
}
}

Prefer to replace the whole token stylesheet instead of adding a block? The builder’s Full token CSS export is a complete hc.tokens.css (every built-in theme plus yours) you can swap in.

Path B — add a token source (shipped in the build)

Section titled “Path B — add a token source (shipped in the build)”

To make the theme a first-class axis that ships in hc.tokens.css (and as its own hc.tokens.color-brand.css axis file), add a DTCG source and register it with the transformer.

  1. Create packages/core/src/tokens/color.brand.tokens.json, mirroring the shape of color.indigo.tokens.json. Reference primitive shades where they exist, or inline a raw hex:

    {
    "$description": "Brand color theme. Emitted under [data-color=\"brand\"].",
    "color": {
    "focus-ring": { "$type": "color", "$value": "{primitive.color.violet.500}" },
    "action": {
    "primary": { "bg": { "$type": "color", "$value": "{primitive.color.violet.600}" },
    "fg": { "$type": "color", "$value": "{primitive.color.white}" },
    "border": { "$type": "color", "$value": "{primitive.color.violet.600}" } },
    "primary-hover": { "bg": { "$type": "color", "$value": "{primitive.color.violet.700}" },
    "border": { "$type": "color", "$value": "{primitive.color.violet.700}" } },
    "primary-soft": { "bg": { "$type": "color", "$value": "color-mix(in srgb, {primitive.color.violet.600} 12%, transparent)" } }
    }
    }
    }
  2. Register it in scripts/build-tokens.mjs by adding one entry to DEFAULT_SOURCES and the namespace to AXIS_NAMESPACES:

    // in DEFAULT_SOURCES, next to the other color.* lines
    { namespace: 'color.brand', file: 'color.brand.tokens.json', selector: '[data-color="brand"]' },
    // in AXIS_NAMESPACES, so it also ships as a standalone axis file
    const AXIS_NAMESPACES = [..., 'color.brand'];
  3. Rebuild: pnpm --filter @hypermedia-components/core build. The transformer resolves your references, classifies which component leaves are theme-dependent, and re-emits them under [data-color="brand"] automatically — the same machinery the built-in themes use.

Either path overrides only the accent tokens; surface / text colors stay owned by data-theme, and control sizing by data-density.

Each theme passes WCAG AA contrast for the button’s text-on-primary combination:

ThemeText colorPrimary bgContrast ratio
defaultwhite#2563eb4.9 : 1
indigowhite#4f46e56.9 : 1
emeraldwhite#0478575.0 : 1
rosewhite#be123c5.7 : 1
amber#111827#f59e0b9.4 : 1

Verified against WebAIM’s contrast checker.

The Theme builder runs this same contrast check live as you pick a color, so a custom theme tells you immediately whether the text-on-primary pair clears AA.