Skip to content

Loading

Twenty-two animations, and not one of them animates an SVG. Every keyframe in this component moves rotate, translate, scale or opacity — the four properties a browser can animate on the compositor without laying out or repainting the page, which matters more here than anywhere else, because an indicator runs while the real work is still going on.

  • Stable
  • React 19
  • 22 variants
  • No animated SVG
  • 2.1 KB gzip
Code
<Loading />
<span class="wayra-loading" data-variant="spinner" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>

Everything is a default you can leave alone. Out of the box it is the eight-spoke system activity indicator, 40px, drawn in currentColor, and hidden from screen readers — because the common case is a spinner sitting beside text that already says what is happening.

import { Loading } from "wayra-ui";
import "wayra-ui/styles.css";
<Loading />
<Loading variant="dots" size="sm" />
<Loading variant="bars" color="success" />
<Loading label="Loading results" />

Worth stating plainly, because the obvious reference gets it backwards: daisyUI’s loading is not pure CSS. Each of its variants is a mask-image pointing at a data:image/svg+xml URI, and the animation is authored inside the SVG with <animate> and <animateTransform>. Its stylesheet contains no @keyframes at all.

That buys one real thing — a single element with no children — and costs three:

  1. prefers-reduced-motion cannot reach it. The animation runs in the SVG’s own document, so a media query in the page’s stylesheet does not stop it. The only remedy left is hiding the indicator entirely.
  2. An animated mask repaints every frame. The mask image changes on each tick, so the compositor cannot cache the layer. It is the most expensive of the three techniques, and it is running precisely while the page is busy.
  3. Those bytes never shake out. The library ships one stylesheet, so a URL-encoded XML blob per variant is delivered to every consumer whether they use that variant or not.

This component takes the third road: plain boxes, gradients and pseudo-elements, with the twelve variants that genuinely need more parts drawing them as decorative <i> children. That single decision removes the need for SVG in sixteen of the twenty-two — dots and bars are exactly the shapes a one-element budget cannot draw, and they are why daisyUI reached for a mask in the first place.

The six ECG traces are the exception, and worth stating precisely, because at a glance it looks like the thing this page just argued against. They do ship an SVG: a stroked polyline is the only way to get round joins and round ends, and the clip-path polygon they were first drawn with produced mitred corners that looked wrong at every size. But the image is static — it is a mask, rasterised once, and what moves behind it is a gradient on a composited translate.

That is the whole distinction. The problem is never an SVG; it is an animated one. Every heartbeat loader on the web animates stroke-dashoffset, which repaints the path on every frame; daisyUI animates <animate> elements inside a mask, which repaints it and puts the motion out of reach of prefers-reduced-motion. Neither happens here.

Code
<Loading variant="spinner" size="lg" />
<Loading variant="ring" size="lg" />
<Loading variant="pulse" size="lg" />
<Loading variant="breathe" size="lg" />
<Loading variant="radar" size="lg" />
<span class="wayra-loading" data-variant="spinner" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="ring" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="pulse" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="breathe" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="radar" data-size="lg" aria-hidden="true"></span>

spinner is the default and the one this component is built around: eight identical spokes carrying a fixed opacity ramp, and a host that turns 45° at a time rather than continuously. The spokes never move relative to each other — stepping the rotation is what makes the ramp appear to travel, so the entire animation is one composited rotate and the eight boxes are placed once and left alone.

ring is the smooth arc with round ends, drawn the way RadialProgress draws its own: a conic gradient cut to a band by a mask, with the two caps as pseudo-elements. pulse and radar are the radar and scanning marks; breathe is the calmest of the set and the right choice for an ambient “working” state that is not blocking anyone.

Code
<Loading variant="dots" size="lg" />
<Loading variant="bars" size="lg" />
<Loading variant="wave" size="lg" />
<Loading variant="grid" size="lg" />
<Loading variant="orbit" size="lg" />
<span class="wayra-loading" data-variant="dots" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="bars" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="wave" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="grid" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="orbit" data-size="lg" aria-hidden="true"><i></i><i></i></span>

These five are the ones that need boxes: dots renders three, bars four, wave five, grid nine and orbit two. They stagger through one shared custom property — --_wayra-loading-index, set once per position — so a variant states a single delay step instead of one rule per box.

Code
<Loading variant="ball" size="lg" />
<Loading variant="beat" size="lg" />
<Loading variant="arc" size="lg" />
<Loading variant="comet" size="lg" />
<Loading variant="square" size="lg" />
<Loading variant="infinity" size="lg" />
<span class="wayra-loading" data-variant="ball" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="beat" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="arc" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="comet" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="square" data-size="lg" aria-hidden="true"></span>
<span class="wayra-loading" data-variant="infinity" data-size="lg" aria-hidden="true"></span>

Six variants that need nothing but the host and its two pseudo-elements. arc is the cheapest drawing in the file — two borders and two transparent ones, so half a turn with square ends — and comet is ring’s turn with a gradient tail instead of a fixed arc.

Code
<Loading variant="vitals" size="lg" />
<Loading variant="ecg" size="lg" />
<Loading variant="heartbeat" size="lg" />
<Loading variant="trace" size="lg" />
<Loading variant="dense" size="lg" />
<Loading variant="spike" size="lg" />
<span class="wayra-loading" data-variant="vitals" data-size="lg" aria-hidden="true"><i></i><i></i></span>
<span class="wayra-loading" data-variant="ecg" data-size="lg" aria-hidden="true"><i></i><i></i></span>
<span class="wayra-loading" data-variant="heartbeat" data-size="lg" aria-hidden="true"><i></i><i></i></span>
<span class="wayra-loading" data-variant="trace" data-size="lg" aria-hidden="true"><i></i><i></i></span>
<span class="wayra-loading" data-variant="dense" data-size="lg" aria-hidden="true"><i></i><i></i></span>
<span class="wayra-loading" data-variant="spike" data-size="lg" aria-hidden="true"><i></i><i></i></span>

All six draw an ECG trace over a ghost of the same line: the whole beat sits there at 12% opacity, and a second layer at full strength moves across it.

The waveform is a static SVG used as a mask-image — a real stroked path, so the joins and the ends are round. Each one states the aspect of its own viewBox, which is why that number is not free: it is what keeps the pen the width it was drawn at.

The three generated ones — vitals, ecg and heartbeat — are not drawn by hand and contain no curves. Each is a sum of Gaussians — one per deflection, P through T, with the T given two different sigmas because a real T wave rises slowly and falls faster — sampled at 1400 points and then reduced with Douglas-Peucker. What comes out is straight segments only, dense where the QRS turns and sparse along the isoelectric line, which is what a machine actually draws. The smoothness of the P and the T is sampling, not interpolation.

The width is not chosen either: the model fixes the height, and the width is solved from the aspect each trace asks for. The deflections sit close enough together that the beat fills its box rather than floating in the middle of a long flat line.

Nothing animates the image, and there are three motions between the six.

vitals draws itself in: its layer is two boxes wide with the edge of the fill at its own midpoint, so that edge is the head, and moving the layer from -50% to 0 walks it across the box exactly once.

ecg and heartbeat send a pulse along the line, a soft band about a quarter of the width crossing from one side to the other.

trace, dense and spike send a trail: a bright head with four steps fading out behind it, which is the motion those three arrived with. In SVG that effect is normally five copies of the line offset along the path; here it is five stops on one gradient, so it stays one layer. The steps are the point — a smooth fade reads as a glow, a stepped one reads as motion.

Either way the whole effect is one composited translate, and the trail costs no keyframes of its own: it reuses the same pass ecg and heartbeat walk.

What differs is the model behind each one:

Variant Aspect Points Drawn The beat
ecg 0.9 17 generated No P wave, wide QRS.
dense 0.83 11 supplied A dense run of small deflections.
spike 0.83 7 supplied One sharp peak on a flatline.
trace 0.98 7 supplied The calm one: a single soft deflection.
vitals 1.24 32 generated A complete cycle, P through T.
heartbeat 1.58 38 generated The full strip, and the only one with a U wave after the T.

The three marked supplied came in as finished SVG designs and are kept segment for segment. Four things have been tuned since: the flat run at each end, cut from about seventy units to twenty-five so the mark is not mostly isoelectric line; the width, narrowed twice by 25% along with the other three; the pen; and the vertical amplitudes, now 30% taller than drawn.

The pen is a literal stroke-width: 3 on the three generated waveforms and 1.75 on the three supplied ones, whose viewBox is about half as tall. Two numbers rather than one because what the eye reads is the ratio — all six land between 3.3% and 4.0% of their own height, so once every trace is scaled to a common box they render at the same weight.

Narrowing is done by scaling the path’s own x coordinates along with the viewBox, never by squeezing the box alone: mask-size is 100% 100%, so a narrower box with the same image would stretch it and thin the pen on every steep stroke.

Code
<span style={{ color: "oklch(60% 0.2 25)" }}>
<Loading variant="dots" size="lg" />
</span>
<Loading variant="dots" color="primary" size="lg" />
<Loading variant="dots" color="secondary" size="lg" />
<Loading variant="dots" color="accent" size="lg" />
<Loading variant="dots" color="neutral" size="lg" />
<Loading variant="dots" color="info" size="lg" />
<Loading variant="dots" color="success" size="lg" />
<Loading variant="dots" color="warning" size="lg" />
<Loading variant="dots" color="error" size="lg" />
<span style="color: oklch(60% 0.2 25)"><span class="wayra-loading" data-variant="dots" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span></span>
<span class="wayra-loading" data-variant="dots" data-color="primary" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="secondary" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="accent" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="neutral" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="info" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="success" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="warning" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="dots" data-color="error" data-size="lg" aria-hidden="true"><i></i><i></i><i></i></span>

color has no default, and it is the one place this component parts company with the rest of the library. Left off, the indicator draws in currentColor — so one placed inside a Button, a colored banner or a link takes the text color around it without being told, which is what an indicator inside another control almost always wants. The first mark above is a plain <Loading variant="dots" /> inside a red paragraph.

Naming a role opts into the palette through the same data-color every other component takes. There is no third state: the role solid is used at full strength, exactly as Progress and RadialProgress use theirs.

Code
<Loading size="xs" />
<Loading size="sm" />
<Loading size="md" />
<Loading size="lg" />
<Loading size="xl" />
<span class="wayra-loading" data-variant="spinner" data-size="xs" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="spinner" data-size="sm" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="spinner" data-size="md" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="spinner" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="spinner" data-size="xl" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
Size Box Stroke
xs 24px 3px
sm 32px 4px
md 40px 5px
lg 48px 6px
xl 56px 7px

Measured with the default --wayra-size-selector of 0.25rem. Like RadialProgress’s, this ladder steps evenly, and it starts above the one the selection controls walk: an indicator has to read at a glance, and the six ECG traces need height for a spike. The box column is the height of every variant, and the width equals it for all but the six ECG traces, which multiply it by the aspect in the table above. The stroke column does not reach those six: their pen lives in the mask at 3.3% to 4.0% of the drawing’s height, so it scales with the box rather than deriving from --wayra-loading-stroke.

Stroke is not a second free number — it is a fixed eighth of the box, and every other measurement in every variant (dot diameter, bar width, spoke length, grid gap) is a fraction of the same value. Change the box and the whole drawing follows.

Code
<Loading variant="bars" size="lg" />
<Loading variant="bars" size="lg" style={{ "--wayra-loading-speed": "3s" }} />
<Loading variant="bars" size="lg" style={{ "--wayra-loading-speed": "0.4s" }} />
<span class="wayra-loading" data-variant="bars" data-size="lg" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="bars" data-size="lg" style="--wayra-loading-speed: 3s" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="wayra-loading" data-variant="bars" data-size="lg" style="--wayra-loading-speed: 0.4s" aria-hidden="true"><i></i><i></i><i></i><i></i></span>

--wayra-loading-speed is documented API and the only tempo control. Every variant states its own multiple of that one number — spinner runs at 0.8×, bars at 1.1×, breathe at 2× — so raising it slows the whole set in proportion instead of flattening twenty-two rhythms onto one duration.

It is a custom property rather than a speed prop for the same reason RadialProgress has no thickness prop: that would be a CSS declaration wearing a costume. Set it inline for one indicator, on a wrapper for a group, or on :root for the app.

--wayra-loading-size, --wayra-loading-stroke and --wayra-loading-color work the same way when a single instance has to leave the scale or the palette.

The legacy component, and daisyUI, render a bare <span> with a class on it. A screen reader hears nothing at all — not “loading”, not “busy”, nothing. This one has two branches and you pick with a single prop.

Loading your files

Code
<p style={{ display: "flex", alignItems: "center", gap: "0.625rem" }}>
<Loading variant="dots" size="sm" />
Loading your files
</p>
<p style="display:flex; align-items:center; gap:0.625rem">
<span class="wayra-loading" data-variant="dots" data-size="sm" aria-hidden="true"><i></i><i></i><i></i></span>
Loading your files
</p>

With no label the host is aria-hidden="true" and says nothing, because the sentence beside it already does. A spinner that announced itself here would be a second, wordless interruption of the same news.

The default is a default and not a pin — it is written before the prop spread, so aria-hidden={false} takes it back if you are assembling something else.

Loading results
Code
<Loading variant="ring" size="lg" label="Loading results" />
<span class="wayra-loading" data-variant="ring" data-size="lg" role="status"><span class="wayra-loading-label">Loading results</span></span>

label switches the host to role="status" and renders the text inside it, visually hidden. The text goes in the region rather than into an aria-label on purpose: a screen reader reads a region’s content, and a name on an empty one is not reliably read.

Reach for this when the indicator is the only thing on screen — a full-page wait, a panel that has not loaded, a button that replaced its own label. It gives the indicator a name a reader finds; it does not, on its own, make the wait heard. Read on.

A status region is announced when its content changes, not when it appears. MDN puts it plainly: establish the region first, then update it, and “the most reliable way to ensure that live regions are registered is to include them in the initial markup”. So {busy && <Loading label="Loading results" />} — the usual way to show a spinner — inserts the region and its text in the same step, and many browser and screen reader pairs say nothing. No change inside the component can fix that: the region is the component, and it does not exist until you mount it.

What the component does keep: on an indicator that stays mounted, a new label is swapped into the same region, so that change is announced.

When the start of a wait has to be heard, keep a role="status" element of your own mounted and change its text. The indicator inside it stays decorative:

<p role="status">
{busy && <Loading variant="dots" size="sm" />}
{busy ? "Loading results" : ""}
</p>

The paragraph is on the page from the first render, empty; the text arriving in it is the change that gets announced. Put “Results loaded” in it when the wait ends if that is news too. Use role="status" rather than role="alert": a wait is not urgent, and an alert interrupts whatever the reader was hearing.

  • Reduced motion stops every animation in the file, and each variant rests in a state that still reads as busy: a full ring of spokes, three dots, four bars at height. Nothing is caught mid-move, which is the same rule Skeleton follows by parking its sheen off the edge of the box.
  • Windows high contrast keeps a working indicator. Chromium drops gradient backgrounds under forced colors, which would erase ring, comet, radar and the traces’ moving head, so the indicator opts out with forced-color-adjust: none; every drawing here reads one variable, and re-pointing it at CanvasText keeps the whole thing in the system palette.
  • Not focusable, no keyboard. An indicator reports; it accepts no input.
  • Contrast is currentColor by default, so it inherits whatever ratio the surrounding text already cleared. With a role it is the solid at full strength, which every theme fits to 4.5:1 against base-100.
  • Never the only signal for a long wait. If something is going to take a while, say so in words next to the indicator, and use Progress as soon as you know how far along it is.
<!-- ten variants need nothing inside -->
<span
class="wayra-loading"
data-variant="ring"
data-size="md"
aria-hidden="true"
></span>
<!-- twelve draw themselves on decorative boxes -->
<span
class="wayra-loading"
data-variant="bars"
data-color="success"
data-size="lg"
role="status"
>
<i></i><i></i><i></i><i></i>
<span class="wayra-loading-label">Saving</span>
</span>

That markup is the whole component. The stylesheet is written against the class and the data-* attributes, so it works in a template language, in plain HTML, or anywhere React is not — the StylingContract story renders a component and its hand-written twin and asserts they are drawn identically, down to the animation on the boxes.

The boxes are bare <i> elements with no class. The parent’s data-variant already says what they are, and keeping them unlabelled is what keeps hand-written markup short enough to actually write. How many each variant takes:

Boxes Variants
9 grid
8 spinner
5 wave
4 bars
3 dots
2 orbit, vitals, ecg, heartbeat, trace, dense, spike
0 ring, arc, comet, radar, ball, pulse, breathe, beat, square, infinity

Too few and the drawing comes up short; too many and the extras inherit the last position’s timing. The component never gets this wrong — the table matters only for markup you write yourself.

Loading is for a wait of unknown length, and it takes no value. The moment you know how far along you are, the bar is Progress and the ring is RadialProgress, both of which announce the number to assistive tech. An indicator that spins through a known quantity is throwing that away.

A sweeping horizontal bar is already <Progress /> with no value — it is the native :indeterminate state of the element, and duplicating it here as variant="line" would ship the same widget twice under two APIs.

Boxes that stand in for the shape of the content are Skeleton. Use that when you know the layout, and this when you do not have anything to lay out yet.

Covered above: five steps and a custom property. A prop that takes any CSS length is a styling API pretending to be a component API.

An indicator centered in a dimmed sheet is a Modal with a Loading inside it, not a prop on this component. The wait and the thing it blocks are two different concerns.

LoadingProps extends ComponentProps<"span"> minus color and children.

Prop Type Default Description
variant LoadingVariant "spinner" Which of the twenty-two animations to draw.
color WayraColor — Semantic role. Omitted, the indicator draws in currentColor.
size "xs" | "sm" | "md" | "lg" | "xl" "md" Box size, off --wayra-size-selector. Stroke follows.
label ReactNode — Accessible name. Turns the host into a role="status" region; a change is announced, the mount often is not.
unstyled boolean false Drops the class, keeps the attributes and the boxes.

className, style, id, ref and every other span attribute pass straight through. children is deliberately not accepted: the component owns what is inside it.

Do

Checking your connection

A decorative indicator beside the sentence that names the wait. One announcement, in words.

Don't

LoadingChecking your connection

A label on an indicator that already has text beside it. The row now announces twice, and the wordless half says less.

Do

Uploading…

One indicator for one wait, at a size that matches the text it sits with.

Don't

Uploading…

Three variants in one row. The variety is there so a product can pick one and use it everywhere, not so a single screen can use three.