Skip to content

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.

--hz-palette-primary #2563eb #60a5fa
--hz-palette-secondary #7c3aed #a78bfa
--hz-palette-success #15803d #4ade80
--hz-palette-warning #b45309 #fbbf24
--hz-palette-danger #b91c1c #f87171
--hz-palette-info #0e7490 #22d3ee
--hz-palette-black #000000
--hz-palette-white #ffffff
--hz-palette-gray #6b7280 #9ca3af

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.

TokenValueSwatch
--hz-color-surfacevar(--hz-palette-white) var(--hz-palette-black)
--hz-color-surface-mutedcolor-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-textvar(--hz-palette-black) var(--hz-palette-white)
--hz-color-text-mutedvar(--hz-palette-gray)
--hz-color-bordervar(--hz-palette-gray)
--hz-color-blackvar(--hz-palette-black)
--hz-color-whitevar(--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

These seven are a starting set. A component only stamps 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.
IntentTokenDefaultSwatchUse
neutral--hz-intent-neutralvar(--hz-palette-gray)The default — no particular status.
primary--hz-intent-primaryvar(--hz-palette-primary)The brand action.
secondary--hz-intent-secondaryvar(--hz-palette-secondary)The supporting accent.
danger--hz-intent-dangervar(--hz-palette-danger)Destructive actions and error states.
warning--hz-intent-warningvar(--hz-palette-warning)Caution; not yet a failure.
success--hz-intent-successvar(--hz-palette-success)A completed or valid outcome.
info--hz-intent-infovar(--hz-palette-info)Neutral supplementary information.

One vocabulary, every component. Here is danger across the family:

OB

Course closed

Lightning in the area. Clear the course now.

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.
TokenDark value
--hz-color-surfacevar(--hz-palette-black)
--hz-color-surface-mutedcolor-mix(in srgb, var(--hz-palette-gray) 25%, var(--hz-color-surface))
--hz-color-textvar(--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.