Colors & Intent
A two-layer color model: the palette (--hz-palette-*) authors hues per mode, and
the semantic layer (--hz-color-*, --hz-intent-*) maps them to what a
color does and what it means. Dark mode overrides land
mostly on the palette; the semantic layer chains through automatically.
Palette tokens
The --hz-palette-* tokens ship fixed values. Override them to retheme the entire
palette at once. Components and the reference theme never read these directly (see the
doctrine note below). This page and Contrast & Accessibility are the exception: they build
and grade the raw names for review.
Semantic roles & intent
Components never reference the palette directly. They resolve through role tokens, the single
indirection point a theme overrides. Roles come in two families: structural roles (--hz-color-*) name what a color does in the layout, and intent roles (--hz-intent-*) name what a color means.
Structural roles
There are seven. surface, surfaceMuted, and text are
re-authored by the dark theme, so their value changes with the mode. textMuted and border are authored once and follow whatever the palette gives them. black and white are alias roles, identical in both modes. The table shows
the value each one resolves to for the mode you are reading in.
| Token | Value | Swatch |
|---|---|---|
--hz-color-surface | var(--hz-palette-white) var(--hz-palette-black) | |
--hz-color-surface-muted | color-mix(in srgb, var(--hz-palette-gray) 6%, var(--hz-color-surface)) color-mix(in srgb, var(--hz-palette-gray) 25%, var(--hz-color-surface)) | |
--hz-color-text | var(--hz-palette-black) var(--hz-palette-white) | |
--hz-color-text-muted | var(--hz-palette-gray) | |
--hz-color-border | var(--hz-palette-gray) | |
--hz-color-black | var(--hz-palette-black) | |
--hz-color-white | var(--hz-palette-white) |
--hz-color-black and --hz-color-white are absolute anchors for
hover-darkening mixes (Button's solid/active states, Link's hover) and on-media controls
(Lightbox), so they deliberately do not flip in dark. They appear twice: in the palette
section above as the black/white palette source, and here as anchor roles.
The repetition is deliberate: the role is what components actually read.
Intent
Intent is the shared vocabulary components use when color carries meaning: the Intent type in @hyzer-labs/ui/types, which is neutral plus the six status hues. Every intent-bearing component takes the full
set, with neutral as the default when nothing is being signalled. Intent color is reinforcement,
never the only signal. The text carries the meaning.
Each intent has its own role token, one indirection above the palette. Override --hz-intent-* to retarget status colors specifically (a danger red that is not
your brand red), or override the palette and the intents follow. Every intent-bearing surface
(Button, Badge, and Alert intents, plus field error states)
resolves through this layer.
Add your own intents
data-intent="<name>" and lets the theme decide what the name means, so the
vocabulary is yours to grow. Define --hz-intent-<name> in your config and it gets contrast-graded like any built-in. Augment the IntentRegistry interface and intent="yours" type-checks and
autocompletes on every component, while a typo still fails to compile. The Terminal example theme adds two, phosphor and amber, and shows all three steps.| Intent | Token | Default | Swatch | Use |
|---|---|---|---|---|
neutral | --hz-intent-neutral | var(--hz-palette-gray) | The default — no particular status. | |
primary | --hz-intent-primary | var(--hz-palette-primary) | The brand action. | |
secondary | --hz-intent-secondary | var(--hz-palette-secondary) | The supporting accent. | |
danger | --hz-intent-danger | var(--hz-palette-danger) | Destructive actions and error states. | |
warning | --hz-intent-warning | var(--hz-palette-warning) | Caution; not yet a failure. | |
success | --hz-intent-success | var(--hz-palette-success) | A completed or valid outcome. | |
info | --hz-intent-info | var(--hz-palette-info) | Neutral supplementary information. |
One vocabulary, every component. Here is danger across the family:
Course closed
Dark mode
Dark mode is optional, with three equally supported postures. Do nothing and
the sheet follows the reader's system preference on its own. It ships a prefers-color-scheme block that applies only while no data-theme attribute is set, so obeying the system costs you no JavaScript. Pin one look by setting data-theme="dark" on <html> once and stopping
there, or data-theme="light" to hold the default look even for a reader whose
system prefers dark. Or wire a toggle that writes the attribute, like this docs
site does. Components resolve the same role and intent tokens in every posture, so nothing else
in your markup or CSS changes between them.
The toggle in this site's sidebar is exactly this: an icon-only Button that
writes the reader's choice and remembers it. Note what it does not do: it never
signals light by removing the attribute. The system default is scoped to :root:not([data-theme]), so a removed attribute hands a system-dark reader dark
mode and makes the light half of the toggle look broken.
import { Button } from '@hyzer-labs/ui';
import IconSun from '@hyzer-labs/ui/icons/sun';
import IconMoon from '@hyzer-labs/ui/icons/moon';
// Two separate things: whether the reader has made an EXPLICIT choice,
// and what their system prefers. null = no choice yet, so the attribute
// stays off and the sheet's prefers-color-scheme block picks the
// default. Following the system needs no JS at all.
let choice = $state(null); // 'light' | 'dark' | null
let systemDark = $state(false);
const dark = $derived(choice ? choice === 'dark' : systemDark);
$effect(() => {
const stored = localStorage.getItem('hz-theme');
if (stored === 'light' || stored === 'dark') choice = stored;
// Only so the button can show the right icon while no choice is set.
const q = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = q.matches;
const onChange = (e) => (systemDark = e.matches);
q.addEventListener('change', onChange);
return () => q.removeEventListener('change', onChange);
});
// WRITE the choice; never signal light by removing the attribute. The
// system default is :root:not([data-theme]), so a removed attribute
// hands a system-dark reader dark mode and the light half of the
// toggle appears to do nothing.
$effect(() => {
if (choice) document.documentElement.setAttribute('data-theme', choice);
else document.documentElement.removeAttribute('data-theme');
});
function toggleTheme() {
choice = dark ? 'light' : 'dark';
localStorage.setItem('hz-theme', choice);
}
<Button
variant="ghost"
intent="neutral"
ariaLabel={dark ? 'Switch to light theme' : 'Switch to dark theme'}
aria-pressed={dark}
onclick={toggleTheme}
>
{#snippet iconStart()}
{#if dark}<IconSun />{:else}<IconMoon />{/if}
{/snippet}
</Button>Overrides
The base tokens are the default theme: what a page renders with no data-theme attribute set, and light is how that default looks. Dark is a named theme layered over it, a set
of overrides in [data-theme="dark"]. Out of the box --hz-color-surface and --hz-color-text swap, --hz-color-surface-muted strengthens its gray tint (6% is invisible over black),
and every hue in --hz-palette-* lightens to a companion that keeps WCAG AA as
text on dark surfaces. Almost nothing else in the semantic layer is re-authored. text-muted and border follow gray, and every intent
follows its hue:
A named theme may override any tier, including the palette, and the dark theme already does, right here. The rule is not that the palette is mode-static; the rule is that components and theme sheets resolve through role (--hz-color-*) and intent (--hz-intent-*) tokens, never the palette directly. Palette is referenced in exactly one place: the token source, where roles and intents are defined (--hz-color-surface: var(--hz-palette-white)). That indirection is the whole point.
| Token | Dark value |
|---|---|
--hz-color-surface | var(--hz-palette-black) |
--hz-color-surface-muted | color-mix(in srgb, var(--hz-palette-gray) 25%, var(--hz-color-surface)) |
--hz-color-text | var(--hz-palette-white) |
--hz-palette-primary | #60a5fa |
--hz-palette-secondary | #a78bfa |
--hz-palette-danger | #f87171 |
--hz-palette-warning | #fbbf24 |
--hz-palette-success | #4ade80 |
--hz-palette-info | #22d3ee |
--hz-palette-gray | #9ca3af |
Any role can be overridden the same way, including intents. If your danger red reads too harsh
on a dark surface, set --hz-intent-danger inside [data-theme="dark"] and every intent-bearing surface follows.
For WCAG ratios, luminance, and in-situ previews of every pairing, see Contrast & Accessibility; for override recipes and the config/CLI workflow, see Theming → Tokens & Overrides.