Utilities
Three families of utility class, from least to most opt-in: the always-on .sr-only, component conventions the theme already ships, and a generated sheet of
token-derived helpers you import separately. It also covers the JavaScript helpers the package
exports.
Always available: .sr-only
You already have this: there is nothing to opt into beyond the theme itself. .sr-only hides content visually while keeping it available to screen readers. The content stays in the accessibility
tree, clipped to a 1px box and taken out of the visual flow. It ships in the reference theme's base.css, unlayered, whenever @hyzer-labs/ui/theme is imported. Button (loading label), Link (external-link hint), Checkbox, RadioGroup, Toggle, and the Field scaffold's hideLabel already render class="sr-only".
<button type="button">
<IconX aria-hidden="true" />
<span class="sr-only">Close</span>
</button>No component ships the rule itself; components only ever emit the class name. The theme is
what makes it visually hidden. Source, from theme/base.css:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}Opt-in component classes
One step up: apply a class the theme already ships, no new import required. Conventions like .hz-card-title and .hz-banner-title ship inside their own component
theme sheets (theme/components/card.css, theme/components/banner.css) rather than the generated utility sheet. They style
whatever heading or lead element you bring, at whatever level the page needs, because headings
are not load-bearing for Card or Banner the way they are for Modal or Accordion. The full
catalog, alongside every component's data-*/hz-* hooks, lives on Theming → Styling Components.
Course conditions
Fairways dry, greens rolling fast.<Card class="hz-card--outlined">
<h3 class="hz-card-title">Course conditions</h3>
Fairways dry, greens rolling fast.
</Card><Banner intent="info">
<strong class="hz-banner-title">Tee times open Monday</strong>
Book early. Weekends fill fast.
</Banner>The opt-in sheet
The biggest commitment of the three: an additional sheet, imported explicitly. A utility class is a token-derived, single-property helper: one class, one declaration, resolved from a design token. That is the whole definition; it does not depend on where the class is or is not used.
Utilities are for ad-hoc spots like nudging one element or tinting one line of text. They are not an alternative layout system. Components already own their spacing (gap/padding props, thedata-padding/data-gapscales) and the density system (--hz-space-near/--hz-space-away). The utility sheet deliberately does not reproduce that surface: it exposes the fixed--hz-space-*scale for margins only, never the density near/away distances, and no padding helpers at all (padding is owned by components).
utilities.css is generated output, exactly like tokens.css: rendered
from the same resolved token model by hyzer generate --utilities, never edited by
hand. It is opt-in. Import it explicitly, like a theme sheet, and if you never import it you
ship none of these bytes. If your config extends the intent vocabulary or the space scale, the
matching classes are generated automatically, with nothing to hand-maintain.
$ hyzer generate --utilities
No config found (looked for hyzer.config.ts, hyzer.config.js, hyzer.config.mjs), using the base schema.
wrote hyzer-tokens.css (full, 84 tokens)
wrote hyzer-utilities.css
contrast: 92 pairings checked, all pass WCAG AAThe flag opts in for a single run; set utilities: true (or { output: '...' }) in hyzer.config.ts to opt in every run. The
full config surface is on Config & CLI.
import '@hyzer-labs/ui/utilities.css';Every rule is unlayered, single-class specificity (0,1,0), with no !important: the same posture as .sr-only above. A deliberately applied
utility beats the layered reference theme. Your own unlayered class of equal specificity still wins
on source order, so import your stylesheet after the utility sheet if you mean to override one.
Color utilities
Four families (.hz-text-*, .hz-bg-*, .hz-border-* and .hz-fill-*), each with its role helpers plus one class per resolved intent. One
property each, so .hz-border-danger sets border-color and nothing
else: bring your own width and style. .hz-fill-* is for SVG.
These are for ad-hoc spots. Components that own a tinted surface (Badge, Alert, Banner)
keep doing that themselves, and reaching for a background utility to restyle one is the wrong
tool. Use their intent prop or their theme hooks.
Free shipping over $75. In stock
<p>
Free shipping over $75.
<span class="hz-text-success hz-m-inline-start-xs">In stock</span>
</p>| Class | Declaration | Note |
|---|---|---|
.hz-text | color: var(--hz-color-text) | Resets inherited color back to the base text role — e.g. inside a tinted region. |
.hz-text-muted | color: var(--hz-color-text-muted) | The muted text role — de-emphasized copy. |
.hz-bg | background-color: var(--hz-color-surface) | The base surface role. |
.hz-bg-muted | background-color: var(--hz-color-surface-muted) | The subdued opaque surface — quiet panels, code blocks. |
.hz-border | border-color: var(--hz-color-border) | Color only. Bring your own border-width and border-style. |
.hz-fill | fill: var(--hz-color-text) | For SVG. Matches body text. |
.hz-fill-muted | fill: var(--hz-color-text-muted) | For SVG. |
And one class per intent, in every family. Every cell references the intent token, so a remapped or added intent flows straight through with no regeneration needed:
| Intent | color | background-color | border-color | fill | Preview |
|---|---|---|---|---|---|
--hz-intent-neutral | .hz-text-neutral | .hz-bg-neutral | .hz-border-neutral | .hz-fill-neutral | neutral |
--hz-intent-primary | .hz-text-primary | .hz-bg-primary | .hz-border-primary | .hz-fill-primary | primary |
--hz-intent-secondary | .hz-text-secondary | .hz-bg-secondary | .hz-border-secondary | .hz-fill-secondary | secondary |
--hz-intent-danger | .hz-text-danger | .hz-bg-danger | .hz-border-danger | .hz-fill-danger | danger |
--hz-intent-warning | .hz-text-warning | .hz-bg-warning | .hz-border-warning | .hz-fill-warning | warning |
--hz-intent-success | .hz-text-success | .hz-bg-success | .hz-border-success | .hz-fill-success | success |
--hz-intent-info | .hz-text-info | .hz-bg-info | .hz-border-info | .hz-fill-info | info |
Margin utilities
Logical properties only, with no mt/mb/ml/mr, so a single-direction nudge (block-start, block-end, inline-start, inline-end) stays correct under RTL and vertical
writing modes. Seven families per --hz-space-* rung. Every cell below references the
token var, so your space override flows straight through with no regeneration needed.
| Family | Property | none | xs | sm | md | lg | xl |
|---|---|---|---|---|---|---|---|
| All sides | margin | .hz-m-none | .hz-m-xs | .hz-m-sm | .hz-m-md | .hz-m-lg | .hz-m-xl |
| Block axis (both) | margin-block | .hz-m-block-none | .hz-m-block-xs | .hz-m-block-sm | .hz-m-block-md | .hz-m-block-lg | .hz-m-block-xl |
| Block start | margin-block-start | .hz-m-block-start-none | .hz-m-block-start-xs | .hz-m-block-start-sm | .hz-m-block-start-md | .hz-m-block-start-lg | .hz-m-block-start-xl |
| Block end | margin-block-end | .hz-m-block-end-none | .hz-m-block-end-xs | .hz-m-block-end-sm | .hz-m-block-end-md | .hz-m-block-end-lg | .hz-m-block-end-xl |
| Inline axis (both) | margin-inline | .hz-m-inline-none | .hz-m-inline-xs | .hz-m-inline-sm | .hz-m-inline-md | .hz-m-inline-lg | .hz-m-inline-xl |
| Inline start | margin-inline-start | .hz-m-inline-start-none | .hz-m-inline-start-xs | .hz-m-inline-start-sm | .hz-m-inline-start-md | .hz-m-inline-start-lg | .hz-m-inline-start-xl |
| Inline end | margin-inline-end | .hz-m-inline-end-none | .hz-m-inline-end-xs | .hz-m-inline-end-sm | .hz-m-inline-end-md | .hz-m-inline-end-lg | .hz-m-inline-end-xl |
A visual scale. Each chip's leading gap is a real margin-inline-start utility, walking the space scale from none to xl:
Source
The generated sheet, in full:
/**
* @hyzer-labs/ui utilities
*
* GENERATED FILE — do not edit by hand.
* Generated by `hyzer generate --utilities` (or set `utilities` in your
* hyzer.config) from your tokens. Regenerate rather than editing here —
* any hand edits are lost on the next run.
*
* Opt-in — import this sheet explicitly, like a theme sheet
* (`@hyzer-labs/ui/utilities.css`). A consumer who never imports it ships
* zero of these bytes.
*
* Definition: a utility class is a token-derived, single-property
* helper — one class, one declaration, resolved from a design token.
* That is the whole definition; it does not depend on where the class
* is or is not used.
*
* Anti-goal: utilities are for ad-hoc spots — nudging one element,
* tinting one line of text — not an alternative layout system.
* Components already own their spacing (gap/padding props, the
* data-padding/data-gap scales) and the density system
* (--hz-space-near/--hz-space-away). The utility sheet deliberately
* does not reproduce that surface: it exposes the fixed --hz-space-*
* scale for margins only, never the density near/away distances, and
* no padding helpers at all (padding is owned by components).
*
* Unlayered, single-class specificity (0,1,0), no !important — mirrors
* .sr-only: a deliberately-applied utility beats the layered reference
* theme, while a consumer's own unlayered class of equal specificity
* still wins by source order.
*/
/* =====================================================================
* Color utilities
* Role + intent helpers for text, background, border and fill, resolved from
* --hz-color-* / --hz-intent-*. One property each: .hz-border-* sets only
* border-color, so bring your own border-width/style; .hz-fill-* is for SVG.
* Components that own a surface (Badge, Alert, Banner) keep doing so — these
* are for ad-hoc spots, not for restyling a component.
* ===================================================================== */
.hz-text {
color: var(--hz-color-text);
}
.hz-text-muted {
color: var(--hz-color-text-muted);
}
.hz-text-neutral {
color: var(--hz-intent-neutral);
}
.hz-text-primary {
color: var(--hz-intent-primary);
}
.hz-text-secondary {
color: var(--hz-intent-secondary);
}
.hz-text-danger {
color: var(--hz-intent-danger);
}
.hz-text-warning {
color: var(--hz-intent-warning);
}
.hz-text-success {
color: var(--hz-intent-success);
}
.hz-text-info {
color: var(--hz-intent-info);
}
.hz-bg {
background-color: var(--hz-color-surface);
}
.hz-bg-muted {
background-color: var(--hz-color-surface-muted);
}
.hz-bg-neutral {
background-color: var(--hz-intent-neutral);
}
.hz-bg-primary {
background-color: var(--hz-intent-primary);
}
.hz-bg-secondary {
background-color: var(--hz-intent-secondary);
}
.hz-bg-danger {
background-color: var(--hz-intent-danger);
}
.hz-bg-warning {
background-color: var(--hz-intent-warning);
}
.hz-bg-success {
background-color: var(--hz-intent-success);
}
.hz-bg-info {
background-color: var(--hz-intent-info);
}
.hz-border {
border-color: var(--hz-color-border);
}
.hz-border-neutral {
border-color: var(--hz-intent-neutral);
}
.hz-border-primary {
border-color: var(--hz-intent-primary);
}
.hz-border-secondary {
border-color: var(--hz-intent-secondary);
}
.hz-border-danger {
border-color: var(--hz-intent-danger);
}
.hz-border-warning {
border-color: var(--hz-intent-warning);
}
.hz-border-success {
border-color: var(--hz-intent-success);
}
.hz-border-info {
border-color: var(--hz-intent-info);
}
.hz-fill {
fill: var(--hz-color-text);
}
.hz-fill-muted {
fill: var(--hz-color-text-muted);
}
.hz-fill-neutral {
fill: var(--hz-intent-neutral);
}
.hz-fill-primary {
fill: var(--hz-intent-primary);
}
.hz-fill-secondary {
fill: var(--hz-intent-secondary);
}
.hz-fill-danger {
fill: var(--hz-intent-danger);
}
.hz-fill-warning {
fill: var(--hz-intent-warning);
}
.hz-fill-success {
fill: var(--hz-intent-success);
}
.hz-fill-info {
fill: var(--hz-intent-info);
}
/* =====================================================================
* Margin utilities (logical properties)
* Seven families per --hz-space-* rung: margin, margin-block(-start/-end),
* margin-inline(-start/-end). No padding — padding is owned by components.
* ===================================================================== */
.hz-m-none {
margin: var(--hz-space-none);
}
.hz-m-xs {
margin: var(--hz-space-xs);
}
.hz-m-sm {
margin: var(--hz-space-sm);
}
.hz-m-md {
margin: var(--hz-space-md);
}
.hz-m-lg {
margin: var(--hz-space-lg);
}
.hz-m-xl {
margin: var(--hz-space-xl);
}
.hz-m-block-none {
margin-block: var(--hz-space-none);
}
.hz-m-block-xs {
margin-block: var(--hz-space-xs);
}
.hz-m-block-sm {
margin-block: var(--hz-space-sm);
}
.hz-m-block-md {
margin-block: var(--hz-space-md);
}
.hz-m-block-lg {
margin-block: var(--hz-space-lg);
}
.hz-m-block-xl {
margin-block: var(--hz-space-xl);
}
.hz-m-block-start-none {
margin-block-start: var(--hz-space-none);
}
.hz-m-block-start-xs {
margin-block-start: var(--hz-space-xs);
}
.hz-m-block-start-sm {
margin-block-start: var(--hz-space-sm);
}
.hz-m-block-start-md {
margin-block-start: var(--hz-space-md);
}
.hz-m-block-start-lg {
margin-block-start: var(--hz-space-lg);
}
.hz-m-block-start-xl {
margin-block-start: var(--hz-space-xl);
}
.hz-m-block-end-none {
margin-block-end: var(--hz-space-none);
}
.hz-m-block-end-xs {
margin-block-end: var(--hz-space-xs);
}
.hz-m-block-end-sm {
margin-block-end: var(--hz-space-sm);
}
.hz-m-block-end-md {
margin-block-end: var(--hz-space-md);
}
.hz-m-block-end-lg {
margin-block-end: var(--hz-space-lg);
}
.hz-m-block-end-xl {
margin-block-end: var(--hz-space-xl);
}
.hz-m-inline-none {
margin-inline: var(--hz-space-none);
}
.hz-m-inline-xs {
margin-inline: var(--hz-space-xs);
}
.hz-m-inline-sm {
margin-inline: var(--hz-space-sm);
}
.hz-m-inline-md {
margin-inline: var(--hz-space-md);
}
.hz-m-inline-lg {
margin-inline: var(--hz-space-lg);
}
.hz-m-inline-xl {
margin-inline: var(--hz-space-xl);
}
.hz-m-inline-start-none {
margin-inline-start: var(--hz-space-none);
}
.hz-m-inline-start-xs {
margin-inline-start: var(--hz-space-xs);
}
.hz-m-inline-start-sm {
margin-inline-start: var(--hz-space-sm);
}
.hz-m-inline-start-md {
margin-inline-start: var(--hz-space-md);
}
.hz-m-inline-start-lg {
margin-inline-start: var(--hz-space-lg);
}
.hz-m-inline-start-xl {
margin-inline-start: var(--hz-space-xl);
}
.hz-m-inline-end-none {
margin-inline-end: var(--hz-space-none);
}
.hz-m-inline-end-xs {
margin-inline-end: var(--hz-space-xs);
}
.hz-m-inline-end-sm {
margin-inline-end: var(--hz-space-sm);
}
.hz-m-inline-end-md {
margin-inline-end: var(--hz-space-md);
}
.hz-m-inline-end-lg {
margin-inline-end: var(--hz-space-lg);
}
.hz-m-inline-end-xl {
margin-inline-end: var(--hz-space-xl);
}Contrast: what is graded and what is not
Every.hz-text-<intent>class maps to a pairing already checked against WCAG AA: intent text colors are AA-verified on the two surface roles only (--hz-color-surfaceand--hz-color-surface-muted, both light and dark). On any other background, contrast is yours to check.
That includes a background utility. .hz-bg-danger is not one of those two
surfaces, so .hz-text-danger on .hz-bg-danger is a pairing nothing
has graded, and several of the intents fail it outright. The combination the report does cover
is the one Banner ships: text in the surface role on a solid intent fill, which flips
with the mode and stays legible in both. Reach for .hz-bg or .hz-bg-muted under intent-colored text. For a solid intent fill, set the text to the
surface role rather than an intent.
.hz-border-* is exempt either way: a border is non-text, so it answers to WCAG 1.4.11 (3:1 against what it sits on) rather than to the 4.5:1 text rule.
Beyond that the sheet introduces no new pairings. See Contrast & Accessibility for the full report, and Colors & Intent for the intent vocabulary itself.
JavaScript utilities
Not every utility is a class. The package also exports plain functions that are pure and SSR-safe, and touch no DOM:
- Contrast math:
contrastRatio,gradeContrast,bestLevel,bestLevelLarge,relativeLuminance,mixSrgb,hexToRgb, andrgbToHex, from the package root or@hyzer-labs/ui/utils. Grade any two hex colors against WCAG with the same functions behind the pairing checker. See Verify your palette for using them in a unit test. toFormErrors: reshapes a plain field-name → message record into the error array Form renders as its linked summary.announce: sends a message to a reused, visually hidden live region for screen readers, from@hyzer-labs/ui/observers. It pairs naturally with an observer callback that just loaded more content or noticed a change.