Loading
An accessible loading indicator in four variants: spinner, ring, dots, and bar. All four are indeterminate on their own; pass a value and bar becomes a linear progress bar while ring becomes a circular arc with a centered readout, and spinner and dots stay indeterminate.
Import
import { Loading } from "@hyzer-labs/ui"Demo
Loading is indeterminate unless you give it a value. bar (the default), spinner (the same IconLoader glyph Button's loading state renders), ring (a rotating loader whose arc grows and shrinks), and dots (a three-dot
ellipsis loader) all say "something is happening" with no value attached. Add a value and ring becomes a determinate circular arc: static, shown here alongside its
indeterminate spin. spinner and dots stay indeterminate only,
so a value passed to either is ignored (with a warning in development).
<Loading label="Loading results" /> <!-- variant="bar" (default), indeterminate -->
<Loading variant="spinner" label="Saving" /> <!-- indeterminate spin -->
<Loading variant="ring" label="Loading" size="lg" /> <!-- indeterminate: a rotating, pulsing arc -->
<Loading variant="ring" value={62} label="Uploading" showValue size="lg" /> <!-- determinate: a static arc -->
<Loading variant="dots" label="Loading" />Passing a value turns the bar into a determinate native <progress value max>, and the ring into an SVG arc that fills to value / max. showValue renders a
formatted readout: inline beside the bar, centered inside the ring. That same string
feeds aria-valuetext when a custom format or a max other than 100 is in play.
let value = $state(62);
<Loading {value} label="Uploading photos" showValue /> <!-- linear bar -->
<Loading variant="ring" {value} label="Uploading photos" showValue size="lg" /> <!-- circular ring -->format feeds both the visible readout and aria-valuetext. Pass
one for any non-percentage unit, on either the linear bar or the circular ring.
<Loading
value={3}
max={5}
label="Import"
format={(v, max) => `${v} of ${max} files`}
showValue
/>
<!-- the ring has far less room, so a shorter format reads better centered inside it -->
<Loading
variant="ring"
value={3}
max={5}
label="Import"
format={(v, max) => `${v}/${max}`}
showValue
size="lg"
/>Every intent, each on its own row of spinner, ring, bar, and dots. --hz-loading-fill changes color
and nothing else, and it reaches every variant alike. intent defaults to primary rather than neutral, since a fill reads as an accent.
<!-- neutral -->
<Loading variant="spinner" intent="neutral" label="neutral, spinner" />
<Loading variant="ring" intent="neutral" label="neutral, ring" />
<Loading variant="bar" intent="neutral" label="neutral, bar" />
<Loading variant="dots" intent="neutral" label="neutral, dots" />
<!-- primary -->
<Loading variant="spinner" intent="primary" label="primary, spinner" />
<Loading variant="ring" intent="primary" label="primary, ring" />
<Loading variant="bar" intent="primary" label="primary, bar" />
<Loading variant="dots" intent="primary" label="primary, dots" />
<!-- secondary -->
<Loading variant="spinner" intent="secondary" label="secondary, spinner" />
<Loading variant="ring" intent="secondary" label="secondary, ring" />
<Loading variant="bar" intent="secondary" label="secondary, bar" />
<Loading variant="dots" intent="secondary" label="secondary, dots" />
<!-- danger -->
<Loading variant="spinner" intent="danger" label="danger, spinner" />
<Loading variant="ring" intent="danger" label="danger, ring" />
<Loading variant="bar" intent="danger" label="danger, bar" />
<Loading variant="dots" intent="danger" label="danger, dots" />
<!-- warning -->
<Loading variant="spinner" intent="warning" label="warning, spinner" />
<Loading variant="ring" intent="warning" label="warning, ring" />
<Loading variant="bar" intent="warning" label="warning, bar" />
<Loading variant="dots" intent="warning" label="warning, dots" />
<!-- success -->
<Loading variant="spinner" intent="success" label="success, spinner" />
<Loading variant="ring" intent="success" label="success, ring" />
<Loading variant="bar" intent="success" label="success, bar" />
<Loading variant="dots" intent="success" label="success, dots" />
<!-- info -->
<Loading variant="spinner" intent="info" label="info, spinner" />
<Loading variant="ring" intent="info" label="info, ring" />
<Loading variant="bar" intent="info" label="info, bar" />
<Loading variant="dots" intent="info" label="info, dots" />Every size, each on its own row of spinner, ring, bar, and dots. --hz-loading-size sizes every
variant from data-size.
<!-- sm -->
<Loading variant="spinner" size="sm" label="Loading, sm, spinner" />
<Loading variant="ring" size="sm" label="Loading, sm, ring" />
<Loading variant="bar" size="sm" label="Loading, sm, bar" />
<Loading variant="dots" size="sm" label="Loading, sm, dots" />
<!-- md -->
<Loading variant="spinner" size="md" label="Loading, md, spinner" />
<Loading variant="ring" size="md" label="Loading, md, ring" />
<Loading variant="bar" size="md" label="Loading, md, bar" />
<Loading variant="dots" size="md" label="Loading, md, dots" />
<!-- lg -->
<Loading variant="spinner" size="lg" label="Loading, lg, spinner" />
<Loading variant="ring" size="lg" label="Loading, lg, ring" />
<Loading variant="bar" size="lg" label="Loading, lg, bar" />
<Loading variant="dots" size="lg" label="Loading, lg, dots" />--hz-loading-speed sets how long one cycle takes. --hz-loading-pulse-width sets the width of the bar's moving highlight (a
percentage of the bar, default 150%; bar only), and wider reads softer. Rotation always
runs at a constant rate, because an eased loop stalls and lurches on every cycle. The
ring's growing arc and the dots do follow --hz-loading-ease.
With reduced motion on, Loading slows to about half speed instead of freezing, because a paused loader looks finished. The bar also trades its sweep for a pulse in place. Skeleton, which only decorates, goes fully still.
const style = '--hz-loading-speed: 2400ms; --hz-loading-pulse-width: 150%;';
<Loading variant="bar" {style} label="Loading results" /> <!-- pulse width tunes the bar highlight -->
<Loading variant="spinner" {style} label="Saving" /> <!-- spin stays linear -->
<Loading variant="ring" {style} label="Loading" /> <!-- rotation stays linear; the arc-length pulse follows speed -->
<Loading variant="dots" {style} label="Loading" />Props
| Name | Type | Default | Note |
|---|---|---|---|
value | number | — (indeterminate) | Omitted, undefined, or NaN ⇒ indeterminate. There is no separate `indeterminate` boolean: the mode follows `value`, the same way a native `<progress>` does. |
max | number | 100 | |
intent | Intent | 'primary' | Defaults to primary, unlike Badge and Banner, since a fill reads as an accent. See Foundation → Colors & Intent. |
size | 'sm' | 'md' | 'lg' | 'md' | |
variant | 'bar' | 'spinner' | 'ring' | 'dots' | 'bar' | A value on `bar` renders a determinate linear progress bar; a value on `ring` renders a determinate circular arc with a centered readout. `spinner` and `dots` are indeterminate only, so a value passed to either is ignored (with a warning in development). With no value, `ring` rotates continuously while its arc grows and shrinks. |
label | string | — | The accessible name. Required: a bare progressbar has a role but no name, and a missing name logs a warning in development. You can pass aria-label or aria-labelledby through rest instead. |
showValue | boolean | false | Renders a formatted readout beside the bar, or centered inside a determinate ring. Determinate only: ignored (with a warning in development) on any indeterminate presentation (including the indeterminate ring) and on spinner/dots. At sm/md the centered ring readout gets tight, so prefer lg or a larger --hz-loading-size. |
format | (value: number, max: number) => string | (v, max) => `${Math.round((v / max) * 100)}%` | Feeds both the visible readout and aria-valuetext, so what you see and what is announced never differ. Applies to both the bar and the determinate ring. |
class | string | — | Merged after the hz-loading class. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-loading
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-intent | 'primary' | any registered intent | Drives the fill via --hz-loading-fill for every variant/mode (bar, ring, spinner, dots). Spans the intent registry. Default primary — a fill is an accent by nature, unlike Badge/Banner’s neutral default. |
data-size | 'sm' | 'md' | 'lg' | Bar thickness, spinner/ring diameter, and dot size. Default md. |
data-variant | 'bar' | 'spinner' | 'ring' | 'dots' | Default bar. spinner and dots are indeterminate-only — a value passed to either is ignored (with a warning in development). ring is the sole home of circular progress: determinate with a value (a static arc), indeterminate without one (a rotating, arc-length-pulsing loader). |
data-indeterminate | present when there is no value | Present whenever value is absent/NaN on bar or ring, and always for spinner and dots (both indeterminate-only by construction). :not([data-indeterminate]) selects a determinate form — the linear bar or the (value-bearing) circular ring. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-loading-fill | <color> — default var(--hz-intent-primary) | The filled portion — the bar fill, the ring arc, the spinner stroke, and the dot color — switched per intent. |
--hz-loading-track | <color> | The unfilled track / unfilled ring. |
--hz-loading-size | <length> — defaulted per data-size | 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-ring-width | <length> — default calc(var(--hz-loading-size) / 8) | 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-speed | <time> — default calc(var(--hz-duration-base) * 6) ≈ 2.4s | 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-ease | <timing-function> — default var(--hz-ease-standard) | 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-pulse-width | <percentage> — default 150% | 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). |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-loading-bar | the native <progress> | The bar variant’s real progressbar. |
.hz-loading-value | child element | The formatted readout, present only with showValue on a determinate form (the bar, inline; the ring, centered). Never present on the indeterminate ring. |
.hz-loading-spinner | child element | The spinner-variant wrapper — role="progressbar" aria-busy="true". Wraps the indeterminate IconLoader glyph. spinner is indeterminate-only; see ring for determinate circular progress. |
.hz-loading-ring-wrapper | child element | The ring-variant wrapper — role="progressbar". Carries aria-busy="true" around the indeterminate rotating/pulsing arc, or aria-valuemin/max/now (no aria-busy) around the determinate static arc. |
.hz-loading-ring | child element | The ring’s SVG — a static arc when determinate, a continuously rotating arc-length-pulsing loop when indeterminate. role="img" aria-hidden="true" (the wrapper carries the ARIA). |
.hz-loading-ring-track | child element | The ring’s unfilled circle. |
.hz-loading-ring-fill | child element | The ring’s arc circle. stroke-dasharray is a theme hook; for a determinate ring the live stroke-dashoffset fraction is written inline by the component (the Slider-fill precedent), not a documented hook. An indeterminate ring instead animates this circle’s rotation and dash length entirely in the theme. |
.hz-loading-dots | child element | The ellipsis-loader wrapper — role="progressbar" aria-busy="true". Adds no new custom-property hooks: reuses --hz-loading-fill, --hz-loading-size, --hz-loading-speed, and --hz-loading-ease. |
.hz-loading-dot | child element | One of the dots’ three spans. |
Accessibility
A determinate bar (what a value gives you) is a native <progress>. It carries role="progressbar" plus aria-valuenow, aria-valuemin, and aria-valuemax for free, so the component stamps none of them by hand. A determinate ring (variant="ring" with a value) renders a static circular arc instead. That arc is a decorative SVG, so the same ARIA (role="progressbar", aria-valuemin/aria-valuemax/aria-valuenow) is stamped on its wrapping span. Neither determinate form sets aria-busy, because the progress is known.
The indeterminate presentations are a bare bar with no value, the spinning glyph, an indeterminate ring, and the dots. None expose aria-valuenow, since the progress is unknown; each wraps in role="progressbar" aria-busy="true" instead. The glyph, the ring SVG, and the dots are all aria-hidden decoration, so the accessible name carries the meaning.
A name is required: pass label (applied as aria-label on the progressbar element), or an aria-label/aria-labelledby via rest. A missing name logs a warning in development, citing WCAG 4.1.2.
For non-percentage units, pass a custom format. Its output becomes aria-valuetext as well as the visible readout (bar or determinate ring), so what a sighted user reads and what a screen reader announces always match.
Reduced motion is a deliberate exception here. The rest of the library reduces animation to zero under prefers-reduced-motion: reduce. An indeterminate Loading keeps animating, because a frozen loader reads as stalled or broken, which is worse for every user. It runs a calmer version at roughly half speed instead (about 4.8s per cycle at the default speed): the spinner keeps spinning, the dots keep cycling, and the indeterminate ring keeps rotating and pulsing, just more slowly. The bar softens from a traveling sweep to a gentle low-amplitude pulse. The determinate forms, the linear bar and the circular ring whenever a value is present, are static either way, so reduced motion changes nothing for them. Skeleton is the opposite of the indeterminate case: its shimmer/pulse goes fully still under reduced motion, because the placeholder shape is itself the "loading" cue and needs no motion.
References: MDN: progressbar role · WAI-ARIA: progressbar role definition