Tokens & Overrides
Theme tokens come in two layers. Layer 1 is the palette (--hz-palette-*):
single-value hues, authored per theme. Layer 2 is the semantic roles (--hz-color-*) and intents (--hz-intent-*), pure var() indirection that chains through
Layer 1. Override one hue and the change follows everywhere it is used.
Remap or extend Layer 2 directly when you want different wiring instead of a palette override.
Token names and defaults live on Colors & Intent. Duration and easing tokens (--hz-duration-* / --hz-ease-*) follow the same override rules and are documented on Motion. That page also introduces @hyzer-labs/ui/motion: script-side helpers built on those tokens, including
transitions, a scroll-reveal attachment, and a view-transition wrapper.
In plain CSS, with no build step
Import your stylesheet after tokens.css and redefine what you need. These are the four
recipes that cover nearly everything:
/* Override any hue once, in the --hz-palette-* namespace; every role
and intent that references it follows automatically. */
:root {
--hz-palette-primary: #0f766e;
--hz-palette-gray: #64748b; /* border, text-muted, and surface tints follow */
}/* The intent layer is where you rewire: point an intent at a different
palette hue than its default, or add an intent of your own. */
:root {
--hz-intent-danger: var(--hz-palette-secondary); /* remap */
--hz-intent-fairway: #3f6212; /* extend */
}/* Dark may override any tier, including the palette: the same hook the
base sheet uses. Override a hue here and its intents, borders, and
muted tints follow in dark only. Your components keep resolving
through roles and intents either way. */
[data-theme='dark'] {
--hz-palette-primary: #2dd4bf;
}
/* Intents can be re-authored inside a theme too, if you want a mapping
that applies in dark only: */
[data-theme='dark'] {
--hz-intent-fairway: #a3e635;
}
/* Two things you get without writing them:
- the sheet already follows the system preference, so you only need
a script to OVERRIDE it, never to obey it;
- data-theme works on ANY element, not just <html>. One section can
be dark inside a light page, and setting data-theme to your default
theme name puts that section back to the default. See Section
themes. */Dark may override any tier here, including the palette. Your components and the
reference theme never read --hz-palette-* directly, so they keep
resolving through roles and intents either way. What dark lets you
change, and what it does not, is on Section themes.
:root {
--hz-radius-md: 0.625rem; /* buttons, cards, fields */
--hz-density: 0.5rem; /* rescales every near/away distance */
--hz-font-family-sans: 'Inter', system-ui, sans-serif;
}--hz-density above rescales the whole density rhythm from one unit. To point each
nesting depth at your own spacing values instead, see Spacing & Sizing → Bring your own scale.
Or describe it once, in a config
Everything above is hand-written CSS, which is the whole point of this tier: no build step, no
tooling. You can instead keep your system in one typed file and have the sheet generated for
you, with every pairing contrast-graded on each run. Config & CLI covers hyzer.config.ts end to end: the option surface, the hyzer generate modes, the trimmed icon barrel, and the utilities sheet.
The two routes reach the same place. A config resolves to the same two-layer model this page describes, so nothing you learn here is wasted if you adopt one later.
A hue you set under tokens changes the default theme only. The library's own dark
theme is a complete, contrast-tuned set, and it keeps its value for anything it already
covers. To carry a change into dark, set it again under themes.dark, the way the
sample on Config & CLI does for primary.
The plain-CSS route above works differently. A :root rule of your own comes after tokens.css, so it lands in dark at the page level too. That is why the Dark mode
recipe writes a [data-theme='dark'] rule instead. Generate a sheet and it writes that
rule for you.
Verify your palette
The contrast math behind the generate report is public API. contrastRatio gives
the raw WCAG ratio for two colors. gradeContrast turns a foreground/background
pair into pass/fail grades per level and text size. bestLevel and bestLevelLarge name the best level a ratio reaches. parseColor, mixSrgb, relativeLuminance, hexToRgb, and rgbToHex are the conversion pieces.
Write your colors in whatever space suits you
oklch(), hsl(), lab(), color(display-p3 …), a named color and a hex alike. The config reads all of
those, plus color-mix() in all fifteen spaces CSS can interpolate in. It also
reads relative colors: oklch(from var(--brand) calc(l * 0.8) c h). WCAG defines
contrast on sRGB, so a color outside that gamut is graded on its clipped equivalent, and the
generate report names those pairings.A color that cannot stand on its own is refused rather than guessed at: a var(),
a currentColor, or anything part-transparent. A translucent color has no ratio
until it sits on a known backdrop. Refused means a thrown TypeError naming the value, so a test that passes one fails loudly instead of comparing a number that was
never a ratio. (parseColor is the exception: it reports the same refusal by
returning null.)
All of them are pure functions over strings, with no DOM access, so they are safe to run on
the server. They are exported from the package root and @hyzer-labs/ui/utils, and
the resolved palette is importable from @hyzer-labs/ui/tokens.
Assert the pairings your override touches in a unit test:
import { gradeContrast, contrastRatio, mixSrgb } from '@hyzer-labs/ui';
import { palette } from '@hyzer-labs/ui/tokens';
// Your override for --hz-palette-primary
const brand = '#0f766e';
gradeContrast(brand, palette.white).aaNormal; // text on surface
gradeContrast(palette.white, brand).aaNormal; // solid button text
// On surface-muted: the same 6% color-mix the theme derives
contrastRatio(brand, mixSrgb(palette.gray, palette.white, 0.06));For the full methodology and a live pairing checker over every shipped token, see Contrast & Accessibility.