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="teal" data-neutral="slate" is a teal 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
teal
primary
hover
soft
text
ring
lime
primary
hover
soft
text
ring
orange
primary
hover
soft
text
ring
fuchsia
primary
hover
soft
text
ring
data-color valuePrimary bgHover bgText on primaryHueCommon use case
(none) / defaultblue.600blue.700white264°Default neutral SaaS — Linear, AWS-style
tealteal.600teal.700white192°Calm product UI, dashboards, dev tools
limelime.600lime.700white120°Sustainability, agritech, outdoors
orangeorange.600orange.700white48°Energetic / productivity, commerce
fuchsiafuchsia.600fuchsia.700white336°Marketing, creator tools, consumer apps

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.

Notice that the table has one shape, not five. Every theme takes 600 for the resting surface, 700 for hover, 500 for the focus ring, and white text. That is a property of the ramps rather than a coincidence: step 600 sits at OKLCH lightness 0.54, and white text clears AA on any hue below lightness 0.554. Picking the accent is picking a hue angle; the rest follows.

The five hues are not arbitrary either: they form the accent pentagon — 72° apart around the hue wheel, anchored at blue. That spacing buys two properties at once. No two accents read as shades of each other (the previous set had indigo 12.6° from blue). And no accent collides with a status color — the old rose sat 9.7° from the error red, so a rose-themed app rendered its primary actions and its error states in visually identical colors. Every pentagon vertex stays ≥ 21° from the error / warning / success hues.

Want a sixth accent? That is what the Theme builder is for — any hue run through the same ladder produces an axis built exactly like these five.

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="lime">
...
</html>

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

<section data-color="orange">
<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-link
--hc-color-link-hover
--hc-color-link-visited
--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 % tint of the theme’s primary color generated with color-mix(in oklab, …) and transparent, so the same value blends correctly on both light and dark surfaces — no per-mode variant required, and the percentage is identical across all five themes.

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.
Section titled “Links are the one accent value that is also theme-dependent”

Every other accent token holds the same value in light and dark: step 600 of the ramp is white-text-safe on any hue, so a primary button does not care which theme it is on. A link is different, because it is text on the page surface rather than a surface of its own — and no single rung clears 4.5:1 against both backgrounds. blue.500 scores 3.61:1 on the light surface; blue.600 scores 3.33:1 on the dark one. So links read 600 / 700 / 800 in light and 400 / 300 / 200 in dark, and each non-default accent ships a color.{name}.dark.tokens.json beside its light file, emitted under the compound [data-theme="dark"][data-color="{name}"] selector.

:visited cannot use a custom property. Engines refuse to resolve var() in a visited-dependent declaration on purpose — resolving it would let a page read the history bit back out through the cascade — so the colour has to be a literal:

/* what you would like to write, and cannot */
a:visited { color: var(--hc-color-link-visited); }

Every one of the fifteen colours clears 4.5:1 against --hc-color-bg and --hc-color-surface, pinned by a spec so a future re-ladder cannot quietly drop one below AA. --hc-color-muted-bg is deliberately outside that guarantee: it is a component tint — button hover, disabled fields, the datagrid head, skeletons, avatars — and a component owns the foreground on its own surface. hc-chat is the one that has to: an assistant bubble is painted with muted-bg and is prose, so a link genuinely lands there. A bubble re-pins its links one rung further along the same ramp, and carries no :visited rule — a layer beats specificity and hc.components sits after hc.base, so the resting rule covers the visited state too, and visited is unified with unvisited inside a bubble. The resting link is the only step that would miss there (light 600 scores 4.40:1, dark 400 scores 3.85:1 worst-accent), and moving it off 600 would decouple links from action.primary, which is what makes a link read as the accent rather than an arbitrary blue.

The token build bakes that literal for you, one rule per theme, into @layer hc.base. This is the one part of the theme a consumer cannot express by hand, which is why it ships here rather than as advice. Bare anchors are styled by default: hc.base.css already owned the document’s background and text, and stopping short of <a> left every anchor outside a component on the UA’s -webkit-link blue and :visited purple — two colours that follow none of data-theme, data-color, or data-neutral.

To unify visited with unvisited — a console is a tool, not a browsing history — override the rule, not the token. Setting --hc-color-link-visited changes nothing on its own, because the baked rule never reads it; that is the whole reason it is baked:

/* unlayered, so it beats everything in hc.base */
a:visited { color: var(--hc-color-link); }

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.teal.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}" },
    "link": { "$type": "color", "$value": "{primitive.color.violet.600}" },
    "link-hover": { "$type": "color", "$value": "{primitive.color.violet.700}" },
    "link-visited": { "$type": "color", "$value": "{primitive.color.violet.800}" },
    "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. Create color.brand.dark.tokens.json alongside it, carrying the dark link steps. Only links need it — every other accent token is the same in both themes (see above):

    {
    "$description": "Brand links on the dark surface.",
    "color": {
    "link": { "$type": "color", "$value": "{primitive.color.violet.400}" },
    "link-hover": { "$type": "color", "$value": "{primitive.color.violet.300}" },
    "link-visited": { "$type": "color", "$value": "{primitive.color.violet.200}" }
    }
    }
  3. Register both in scripts/token-transform.mjs by adding entries to DEFAULT_SOURCES and the namespace to AXIS_NAMESPACES / COLOR_RAMPS_WITH_DARK:

    // in DEFAULT_SOURCES, next to the other color.* lines
    { namespace: 'color.brand', file: 'color.brand.tokens.json', selector: '[data-color="brand"]' },
    // and after them, the compound dark block — both selector forms, because
    // data-theme and data-color need not sit on the same element
    { namespace: 'color.brand.dark', file: 'color.brand.dark.tokens.json',
    selector: '[data-theme="dark"] [data-color="brand"], [data-theme="dark"][data-color="brand"]',
    overlay: ['theme.dark', 'color.brand.dark'] },
    // in AXIS_NAMESPACES, so it also ships as a standalone axis file
    const AXIS_NAMESPACES = [..., 'color.brand'];
    // in COLOR_RAMPS_WITH_DARK, so that file carries both halves
    const COLOR_RAMPS_WITH_DARK = [..., 'brand'];
  4. 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 — by construction rather than by inspection:

ThemeText colorPrimary bgContrast ratio
defaultwhiteblue.6005.31 : 1
tealwhiteteal.6004.83 : 1
limewhitelime.6004.89 : 1
orangewhiteorange.6005.36 : 1
fuchsiawhitefuchsia.6005.70 : 1

All five land in a narrow band because they share a lightness, not because each was tuned. Contrast at a fixed OKLCH lightness barely moves with hue or chroma: sweeping all 360 hues at step 600 gives 4.73–5.86 : 1, and sweeping chroma from 0 to 0.25 at one hue moves the ratio only 5.06 → 5.43. Lightness carries the guarantee on its own, which is why a new accent hue needs no contrast review.

Teal is the strictest of the five and sets the floor at 4.83 : 1. packages/core/test/ramp.test.mjs asserts the whole table — and the pentagon spacing itself — so an accent that fell below AA or drifted toward a status hue would fail the unit suite rather than reach a page.

One consequence worth knowing: step 500 is not a text surface. At lightness 0.62 the saturated hues clear neither white (3.4–4.0 : 1) nor dark text (4.4–5.2 : 1), so 500 is the focus-ring and accent step while 600 carries anything that holds a label.

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.