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.
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):
defaultteallimeorangefuchsiadata-color value | Primary bg | Hover bg | Text on primary | Hue | Common use case |
|---|---|---|---|---|---|
(none) / default | blue.600 | blue.700 | white | 264° | Default neutral SaaS — Linear, AWS-style |
teal | teal.600 | teal.700 | white | 192° | Calm product UI, dashboards, dev tools |
lime | lime.600 | lime.700 | white | 120° | Sustainability, agritech, outdoors |
orange | orange.600 | orange.700 | white | 48° | Energetic / productivity, commerce |
fuchsia | fuchsia.600 | fuchsia.700 | white | 336° | 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.
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)”Orange
Section titled “Orange”Fuchsia
Section titled “Fuchsia”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="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-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-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-bgprimary-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:
- 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.
Links are the one accent value that is also theme-dependent
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); }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.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)" } }}}} -
Create
color.brand.dark.tokens.jsonalongside 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}" }}} -
Register both in
scripts/token-transform.mjsby adding entries toDEFAULT_SOURCESand the namespace toAXIS_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 fileconst AXIS_NAMESPACES = [..., 'color.brand'];// in COLOR_RAMPS_WITH_DARK, so that file carries both halvesconst COLOR_RAMPS_WITH_DARK = [..., '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 — by construction rather than by inspection:
| Theme | Text color | Primary bg | Contrast ratio |
|---|---|---|---|
| default | white | blue.600 | 5.31 : 1 |
| teal | white | teal.600 | 4.83 : 1 |
| lime | white | lime.600 | 4.89 : 1 |
| orange | white | orange.600 | 5.36 : 1 |
| fuchsia | white | fuchsia.600 | 5.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.
Related
Section titled “Related”- Theme builder — generate a custom accent palette from one color, with live contrast checking.
- Density — orthogonal axis for tightness.
- Theming overview — the four-layer model.
- Button, Checkbox, Radio, Pagination — primary consumers of the theme.