Skip to content

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).

62%
<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" />

Props

NameTypeDefaultNote
valuenumber— (indeterminate)Omitted, undefined, or NaN ⇒ indeterminate. There is no separate `indeterminate` boolean: the mode follows `value`, the same way a native `<progress>` does.
maxnumber100
intentIntent'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.
labelstringThe 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.
showValuebooleanfalseRenders 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.
classstringMerged 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

HookValuesStyles
data-intent'primary' | any registered intentDrives 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-indeterminatepresent when there is no valuePresent 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

HookValuesStyles
--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-sizeBar 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.4sBase 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

HookValuesStyles
.hz-loading-barthe native <progress>The bar variant’s real progressbar.
.hz-loading-valuechild elementThe formatted readout, present only with showValue on a determinate form (the bar, inline; the ring, centered). Never present on the indeterminate ring.
.hz-loading-spinnerchild elementThe 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-wrapperchild elementThe 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-ringchild elementThe 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-trackchild elementThe ring’s unfilled circle.
.hz-loading-ring-fillchild elementThe 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-dotschild elementThe 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-dotchild elementOne 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