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.
Themes
Section titled “Themes”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):
defaultindigoemeraldroseamberdata-color value | Primary bg | Hover bg | Text on primary | Common use case |
|---|---|---|---|---|
(none) / default | #2563eb (blue.600) | #1d4ed8 (blue.700) | white | Default neutral SaaS — Linear, AWS-style |
indigo | #4f46e5 (indigo.600) | #4338ca (indigo.700) | white | Modern SaaS — Tailwind, Cal.com |
emerald | #047857 (green.700) | #065f46 (green.800) | white | Fintech / sustainability — Stripe Atlas |
rose | #be123c (rose.700) | #9f1239 (rose.800) | white | Marketing / CRM / healthcare |
amber | #f59e0b (amber.500) | #d97706 (amber.600) | gray.900 | Energetic / 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.
Live preview
Section titled “Live preview”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.
Default (blue)
Section titled “Default (blue)”Notify me
Free
Indigo
Section titled “Indigo”Notify me
Free
Emerald
Section titled “Emerald”Notify me
Free
Notify me
Free
Notify me
Free
Applying it to your page
Section titled “Applying it to your page”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-themeon<html>when present, otherwise the OS preference (prefers-color-scheme). Clicking flips it and writesdata-theme="light|dark"explicitly. -
data-persist="<key>"(optional) stores the choice inlocalStorageand 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 defaultaria-labelfrom the i18n catalog (themeToggle.label; an authoredaria-labelwins). Each change dispatches a bubblinghc:themechangeevent (detail.theme). -
A page that hardcodes
data-theme="dark"keeps working unchanged — the toggle simply starts from that explicit value.
What changes
Section titled “What changes”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-bgprimary-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:
- Button —
data-variant="primary"background and hover state. - Button —
data-variant="ghost"hover background now picks up the theme tint instead of a neutral grey. - Input — the
:focus-visibleborder color follows the theme’s focus-ring shade. - Checkbox —
:checkedfill (when no per-variant override). - Radio —
:checkedfill. - Pagination — the
aria-current="page"cell. - Every component’s
:focus-visiblering — driven directly by--hc-color-focus-ring. ::selection— text-selection highlight usesprimary-soft-bg, so highlighting any prose on the page becomes a low-key brand cue.
Components and tokens deliberately not affected:
data-variant="secondary"(onhc-button) — neutral grey by design. Keepsprimaryvisually distinct as the brand-themed action, whilesecondaryreads 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-edgescroll-hint color) — owned bydata-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.
Authoring your own theme
Section titled “Authoring your own theme”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 builder →
Theme 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.
-
Create
packages/core/src/tokens/color.brand.tokens.json, mirroring the shape ofcolor.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)" } }}}} -
Register it in
scripts/build-tokens.mjsby adding one entry toDEFAULT_SOURCESand the namespace toAXIS_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 fileconst AXIS_NAMESPACES = [..., 'color.brand']; -
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.
Accessibility
Section titled “Accessibility”Each theme passes WCAG AA contrast for the button’s text-on-primary combination:
| Theme | Text color | Primary bg | Contrast ratio |
|---|---|---|---|
| default | white | #2563eb | 4.9 : 1 |
| indigo | white | #4f46e5 | 6.9 : 1 |
| emerald | white | #047857 | 5.0 : 1 |
| rose | white | #be123c | 5.7 : 1 |
| amber | #111827 | #f59e0b | 9.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.
Related
Section titled “Related”- Theme builder — generate a custom accent palette from one color, with live contrast checking.
- Density — orthogonal axis for tightness.
- Tokens overview — the four-layer model.
- Button, Checkbox, Radio, Pagination — primary consumers of the theme.