Styling Components
Components expose a stable styling contract: an hz-* root class, a data-* attribute per variant and state, and a class prop merged
after the root class. The reference theme styles exactly these hooks. It paints them from @layer hz-theme, wrapped in :where(), so everything stays at
single-class specificity and your unlayered CSS wins by default.
Classes and data hooks
The hooks are part of each component's contract, and every component page lists its own under Theme hooks: root class, data-* vocabulary, custom properties,
and the part classes for its children. Variants land as data-variant/data-intent/data-size; interactive state
as data-state and friends.
A prop with a default stamps its attribute on every render, so [data-variant='solid'][data-size='md'] always matches. A prop with no default (an
unset intent, say) leaves the attribute off entirely, which is what lets :not([data-intent]) style the plain case.
/* Every component exposes a stable root class and data-* hooks for its
variants and states. Style them from your own (unlayered) CSS: */
.hz-button {
text-transform: uppercase;
letter-spacing: 0.02em;
}
.hz-button[data-variant='outline'][data-intent='danger'] {
border-style: dashed;
}
/* State hooks cover the interactive lifecycle: */
.hz-field[data-state='error'] input {
background: #fff5f5;
}The class prop
<Button class="cta">Book a tee time</Button>
<style>
/* Rendered as class="hz-button cta". Your class is unlayered, so it
beats the theme with no specificity games. Svelte scoping needs
:global for a class you pass to a component. */
:global(.cta) {
box-shadow: var(--hz-shadow-md);
}
</style>Whether a selector for that class needs :global() depends on where the rule lives,
not on this library. Styling one component instance has three channels, and only one of them needs
the wrapper:
- A stylesheet you import (your
app.css, a theme sheet): plain selectors work. Svelte only scopes styles inside a component's<style>block, so an imported sheet never needs:global(). - Your own component's
<style>block: wrap the selector in:global()when the class was passed to a library component. That class lands on markup the component renders. Svelte scopes your styles to your own template, so an unwrapped selector is silently pruned as unused. This is Svelte's scoping model, common to every Svelte component library. A class on an element in your own markup needs no wrapper. - Custom-property hooks (
--hz-*): no selector reaches into the component at all. Set them inline, on a wrapper, or on any ancestor, and they inherit through the component boundary.
One caveat about layering: styles inside a component's own <style> block are unlayered too. So if you build wrapper components, prefer
styling the hz-* hooks from stylesheets you control rather than re-declaring resets
a theme would need to fight.
Case study: .docs-table
The reference theme paints .hz-table from @layer hz-theme, wrapped in :where(), so unlayered CSS beats it at
any specificity: no !important, no mirroring the theme's selectors. The shipped
Docs example theme (the middle tier on Example Themes) is
built on exactly that: one opt-in wrapper class, .docs-table, and nothing else
about Table changes.
Reference theme, unwrapped
| Hole | Par | Distance |
|---|---|---|
| Hole 3 | 3 | 285 ft |
| Hole 7 | 4 | 412 ft |
| Hole 12 | 5 | 620 ft |
Wrapped in .docs-table
| Hole | Par | Distance |
|---|---|---|
| Hole 3 | 3 | 285 ft |
| Hole 7 | 4 | 412 ft |
| Hole 12 | 5 | 620 ft |
/*
* .docs-table: the worked cascade example. The reference theme paints
* .hz-table from @layer hz-theme, so these unlayered rules win on every
* property they set — no specificity games, no !important.
*/
.docs-table.hz-table-wrap {
border: none;
border-radius: 0;
}
.docs-table .hz-table {
font-size: var(--hz-font-size-sm, 0.875rem);
}
.docs-table .hz-table th,
.docs-table .hz-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--hz-color-border, #6b7280);
vertical-align: top;
}
.docs-table .hz-table thead th {
background-color: transparent;
font-weight: var(--hz-font-weight-semibold, 600);
white-space: nowrap;
}Wrap any table in .docs-table and its borders, padding, and header background
follow this sheet instead, on every property the sheet sets and nothing else. A table with no
wrapper is left exactly as the reference theme paints it. The excerpt above is sliced from the
real shipped @hyzer-labs/ui/theme/examples/docs/docs.css. The full sheet, with
this override in context alongside Ocean and Terminal, is on Example Themes.
Custom-property hooks
Beyond the tokens, some components expose their own knob as a custom property. Override it on
any selector, or per theme by scoping it under [data-theme="dark"]. Every one the
library ships is below; each component's page carries the same rows alongside its data-* and part classes.
| Hook | Values | Component | Tunes |
|---|---|---|---|
--hz-alert-border-width | <length> — default var(--hz-border-width-heavy) | Alert | Thickness of the inline-start accent bar. Defaults to the heaviest border-width token; override with any length or a lighter token. |
--hz-alert-tint | <percentage> — 10% light, 22% dark | Alert | How strongly the intent colour mixes into the surface. Retuning it means re-checking the soft pairings on Contrast & Accessibility. |
--hz-badge-tint | <percentage> — 14% light, 28% dark | Badge | Soft-variant background tint strength. |
--hz-banner-bg | <color> — default var(--hz-intent-neutral) | Banner | The solid fill, switched per intent — override to restyle every intent's bar. |
--hz-banner-fg | <color> — default var(--hz-color-surface) | Banner | On-fill text colour, the surface role so it flips with the mode and keeps text ≥ 4.5:1 in both. |
--hz-banner-padding-block | <length> — default 1.5rem | Banner | Vertical padding of the bar. |
--hz-banner-padding-inline | <length> — default 2.5rem | Banner | Horizontal padding of the bar. |
--hz-blockquote-border-width | <length> — default var(--hz-border-width-heavy) | Blockquote | Thickness of the inline-start accent line. Defaults to the heaviest border-width token; override with any length or a lighter token. The attribution row indent tracks it automatically. |
--hz-blockquote-font-size | <length> — default var(--hz-font-size-xl) | Blockquote | Font size of the quote body. Read, never declared, so it can be set per instance or inherited from any ancestor — the docs site tunes it down to body size for its doctrine notes. |
--hz-breakout-shift | <length> — default calc(50% - 50cqw) | Container | Breakout alignment offset. The default assumes a centered column; set it to 0 in a start-aligned layout so the bleed only grows rightward. |
--hz-button-accent | <color> — default var(--hz-intent-primary) | Button | The accent every variant derives from. Override it to restyle solid, outline, ghost, and soft at once — and it is how you wire up an intent you registered yourself. |
--hz-button-on-accent | <color> — default var(--hz-color-surface) | Button | Text colour on a solid fill. The surface role rather than white, so it flips with the mode and keeps solid text ≥ 4.5:1 in both. |
--hz-button-tint | <percentage> — 14% light, 28% dark | Button | Soft-variant background tint strength — the same recipe and hook pattern as --hz-badge-tint. |
--hz-card-media-size | <length|percentage> — default 40% | Card | The media track in horizontal mode; media fills it. Never declared, so set it yourself on .hz-card or an ancestor. |
--hz-carousel-dot-size | <length> — default 0.5rem | Carousel | Dot diameter (the painted size; the tap target is larger). Declared on .hz-carousel-dot itself, so set it there — declaring it on .hz-carousel will not reach, since the local declaration beats your inherited value. |
--hz-carousel-focus-min-height | <length> — default 12rem | Carousel | Minimum height of .hz-carousel-viewport, data-controls='focus' only. The revealed control row is an absolutely positioned overlay with no reserved layout space, so on a short carousel it can cover most of the slide — this keeps slide content clear of the row regardless of the slide's own height. data-controls='visible' needs no reserved space (its row sits in normal flow below the viewport) and is unaffected. |
--hz-code-block-bg | <color> — default var(--hz-color-surface-muted) | CodeBlock | The code surface fill; the collapse fade fades into it. Suppressed under data-highlighted so a build-time highlighter's own background shows. |
--hz-code-block-fade-height | <length> — default 3rem | CodeBlock | Height of the collapse fade over the clamped edge. |
--hz-code-block-padding | <length> — default 1rem | CodeBlock | Padding inside the code region (pre) — reaches a consumer-supplied pre too. |
--hz-color-swatch-size | <length> — default 2rem | ColorInput | The swatch height; its width follows at 1.5×, so the 3:2 shape is fixed. Declared on input.hz-color — set it there or deeper, not on the .hz-field root, or the local declaration wins over your inherited value. |
--hz-footer-col-min | <length> — default 12rem | Footer | Minimum column width for the auto-fit link grid: how narrow a column may get before the row reflows. No media queries involved — this is the whole knob. |
--hz-image-fade-duration | <time> — default 0.3s | Image | Placeholder crossfade duration. Read at both ends of the fade, so the two stay locked together. |
--hz-image-placeholder-blur | <length> — default 20px | Image | Blur radius on the low-res placeholder. |
--hz-loading-ease | <timing-function> — default var(--hz-ease-standard) | Loading | Easing of the dots cycle and the indeterminate ring’s arc-length pulse — both reverse in place rather than travel, so an ease reads cleanly. The spinner spin, the ring’s rotation, and the bar sweep are always linear, independent of this hook — an eased rotation or looping sweep reads as stuttering (it slows to a stop and restarts each cycle). |
--hz-loading-fill | <color> — default var(--hz-intent-primary) | Loading | The filled portion — the bar fill, the ring arc, the spinner stroke, and the dot color — switched per intent. |
--hz-loading-pulse-width | <percentage> — default 150% | Loading | Width of the indeterminate bar's moving highlight (its “pulse”) as a percentage of the bar's own width. Wider reads softer and more ambient — the peak stays a point at the band center, so a wide value fades gently over a long distance. Values up to ~200% keep the loop seamless. Affects the indeterminate bar only (unrelated to the ring's own arc-length pulse). |
--hz-loading-ring-width | <length> — default calc(var(--hz-loading-size) / 8) | Loading | The ring’s stroke width (both the determinate static arc and the indeterminate rotating/pulsing arc), scaled off --hz-loading-size so it stays proportional to the ring diameter. Applied with vector-effect: non-scaling-stroke so it stays a true length under the SVG viewBox scale. |
--hz-loading-size | <length> — defaulted per data-size | Loading | Bar thickness for the bar; spinner-glyph / ring diameter for the spinner and ring; dot diameter for dots — each defaulted per data-size. |
--hz-loading-speed | <time> — default calc(var(--hz-duration-base) * 6) ≈ 2.4s | Loading | Base duration of the indeterminate animations: the spinner rotation, the dots cycle, and the indeterminate ring’s rotation + arc-length pulse all run at exactly this; the bar sweep runs at 1.6x it. Anchored to the --hz-duration-* scale rather than a bare token, since that scale is tuned for one-shot transitions, not continuous loops. Under reduced motion the effective duration is slowed (≈2x, ≈4.8s), never zeroed — indeterminate Loading keeps animating (the deliberate exception; contrast Skeleton). The determinate bar/ring are static, so this hook is moot for them. |
--hz-loading-track | <color> | Loading | The unfilled track / unfilled ring. |
--hz-logo-brightness | <number> — default 1 | Logo | Applied as filter: brightness(). Usually set through the brightness prop, which stamps it inline only when != 1 — the hook stays directly settable in CSS for container-wide muting. |
--hz-logo-color | <color> — default currentColor | Logo | Read, not declared, in the monochrome fill rule — a wall mutes itself with one override, the Container --hz-breakout-shift precedent for a read-only hook. |
--hz-logo-size | <length> — default clamp(4rem, 8vw, 6rem) | Logo | The normalization base every logo scales against. Override it on a wall container (Cluster/Grid) to resize every logo inside at once. |
--hz-modal-width | <length> — 30/40/52rem by size | Modal | One name across all three sizes: it overrides whichever size is active. data-size="full" ignores it. |
--hz-skeleton-color | <color> | Skeleton | The base block color. Strengthened in dark (the Badge --hz-badge-tint precedent). |
--hz-skeleton-highlight | <color> | Skeleton | The shimmer sweep color. |
--hz-skeleton-speed | <time> — default 1.4s | Skeleton | The shimmer/pulse animation cycle. |
--hz-slider-length | <length> — default 12rem | Slider | The track's block length in vertical orientation. Ignored in horizontal, where the track flexes to fill the row instead. |
--hz-slider-length | <length> — default 12rem | RangeSlider | The track's block length in vertical orientation. Ignored in horizontal, where the track flexes to fill the row instead. |
--hz-slider-thumb-size | <length> — default 1.125rem | Slider | Thumb width and height. The fill edge tracks the thumb centre, so the fill and ticks stay aligned at any size. |
--hz-slider-thumb-size | <length> — default 1.125rem | RangeSlider | Thumb width and height. The fill edge tracks the thumb centre, so the fill and ticks stay aligned at any size. |
--hz-slider-track-height | <length> — default 0.375rem | Slider | Track thickness. |
--hz-slider-track-height | <length> — default 0.375rem | RangeSlider | Track thickness. |
--hz-toggle-height | <length> — default 1.5rem | Toggle | Track height. The thumb derives from it (height minus a 2px inset all round), so there is no separate thumb knob — and the travel stays correct under any override. |
--hz-toggle-width | <length> — default 2.5rem | Toggle | Track width, and the distance the thumb travels. |
--hz-z-popover | <number> — default 200 | Popover | Matters mainly on the non-top-layer fallback path (older browsers) — a real top-layer panel stacks above everything regardless of z-index. |
--hz-z-tooltip | <number> — default 150 | Tooltip | Matters mainly on the non-top-layer fallback path (older browsers) — a real top-layer tooltip stacks above everything regardless of z-index. |
/* Quieter dark alerts, a chunkier toggle: */
[data-theme='dark'] .hz-alert {
--hz-alert-tint: 16%;
}
.hz-field--toggle input {
--hz-toggle-width: 3rem;
--hz-toggle-height: 1.75rem;
}The tint defaults are graded on Contrast & Accessibility. If you retune them, re-check the soft pairings there.
Theme conventions: Card treatments & titles
Some looks are deliberately theme classes rather than component props. Card's surface
treatments (hz-card--outlined, hz-card--elevated) live only in the
reference theme. hz-card-title is an opt-in convention: bring your own heading element
at whatever level the page needs, and the class only styles it. Headings are not load-bearing for
Card the way they are for Modal or Accordion, so they never became API.
Course conditions
Fairways dry, greens rolling fast.Next tee time
Saturday, 8:40, Maple Hill.<Card class="hz-card--outlined">
<h3 class="hz-card-title">Course conditions</h3>
Fairways dry, greens rolling fast.
</Card>
<Card class="hz-card--elevated">
<h3 class="hz-card-title">Next tee time</h3>
Saturday, 8:40, Maple Hill.
</Card>The generated utility sheet and the full catalog of opt-in classes (including .hz-card-title and .hz-banner-title) are documented on Foundation → Utilities.
Going fully headless
Skip the reference theme and the same hooks are your blank canvas: components render as
functional native elements with no appearance opinions. Keep tokens.css for the
custom properties (component fallbacks match the token values exactly, so adding it later
changes nothing), and cherry-pick any reference sheet you do want: @hyzer-labs/ui/theme/components/button.css.
For complete restyles, start from an example: Example Themes shows three of them, and the configs behind the two that are generated.