Skip to content

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

Course card
HoleParDistance
Hole 33285 ft
Hole 74412 ft
Hole 125620 ft

Wrapped in .docs-table

Course card, docs style
HoleParDistance
Hole 33285 ft
Hole 74412 ft
Hole 125620 ft

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.

HookValuesComponentTunes
--hz-alert-border-width<length> — default var(--hz-border-width-heavy)AlertThickness 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% darkAlertHow 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% darkBadgeSoft-variant background tint strength.
--hz-banner-bg<color> — default var(--hz-intent-neutral)BannerThe solid fill, switched per intent — override to restyle every intent's bar.
--hz-banner-fg<color> — default var(--hz-color-surface)BannerOn-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.5remBannerVertical padding of the bar.
--hz-banner-padding-inline<length> — default 2.5remBannerHorizontal padding of the bar.
--hz-blockquote-border-width<length> — default var(--hz-border-width-heavy)BlockquoteThickness 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)BlockquoteFont 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)ContainerBreakout 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)ButtonThe 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)ButtonText 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% darkButtonSoft-variant background tint strength — the same recipe and hook pattern as --hz-badge-tint.
--hz-card-media-size<length|percentage> — default 40%CardThe 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.5remCarouselDot 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 12remCarouselMinimum 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)CodeBlockThe 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 3remCodeBlockHeight of the collapse fade over the clamped edge.
--hz-code-block-padding<length> — default 1remCodeBlockPadding inside the code region (pre) — reaches a consumer-supplied pre too.
--hz-color-swatch-size<length> — default 2remColorInputThe 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 12remFooterMinimum 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.3sImagePlaceholder crossfade duration. Read at both ends of the fade, so the two stay locked together.
--hz-image-placeholder-blur<length> — default 20pxImageBlur radius on the low-res placeholder.
--hz-loading-ease<timing-function> — default var(--hz-ease-standard)LoadingEasing 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)LoadingThe 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%LoadingWidth 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)LoadingThe 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-sizeLoadingBar 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.4sLoadingBase 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>LoadingThe unfilled track / unfilled ring.
--hz-logo-brightness<number> — default 1LogoApplied 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 currentColorLogoRead, 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)LogoThe 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 sizeModalOne name across all three sizes: it overrides whichever size is active. data-size="full" ignores it.
--hz-skeleton-color<color>SkeletonThe base block color. Strengthened in dark (the Badge --hz-badge-tint precedent).
--hz-skeleton-highlight<color>SkeletonThe shimmer sweep color.
--hz-skeleton-speed<time> — default 1.4sSkeletonThe shimmer/pulse animation cycle.
--hz-slider-length<length> — default 12remSliderThe track's block length in vertical orientation. Ignored in horizontal, where the track flexes to fill the row instead.
--hz-slider-length<length> — default 12remRangeSliderThe 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.125remSliderThumb 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.125remRangeSliderThumb 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.375remSliderTrack thickness.
--hz-slider-track-height<length> — default 0.375remRangeSliderTrack thickness.
--hz-toggle-height<length> — default 1.5remToggleTrack 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.5remToggleTrack width, and the distance the thumb travels.
--hz-z-popover<number> — default 200PopoverMatters 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 150TooltipMatters 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.