Spinner
FreeLoading indicators in 4 styles (border, dots, pulse, bars), 5 sizes, 8 color variants and an optional fullscreen overlay.
</>sv-spinner spinnerloadingfeedback
import { SvSpinnerComponent } from '@styloviz/overlays';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Fullscreen overlay
example.htmlAngular
1 lines
<sv-spinner spinnerStyle="border" size="lg" variant="primary" [overlay]="true" label="Loading…" />
Say how long it usually takes
example.htmlAngular
6 lines
<!-- One number. It is SHOWN to the user ("about 25 seconds") and it derives both escalation thresholds — slow at 1.5x, stuck at 4x — so the figure on screen and the moment the spinner admits lateness cannot drift apart. The line disappears the instant it stops being true. --> <sv-spinner label="Importing…" [expectedMs]="25000" [labelVisible]="true" />
Escalate an indefinite wait
example.htmlAngular
13 lines
<!-- A spinner is indistinguishable from a failed request, forever. These two thresholds are both measured from the moment the spinner appears: at 10s it says so, at 45s it offers a way out. Set cancelLabel only when (cancelled) can actually stop the work. --> <sv-spinner [overlay]="true" label="Importing…" [slowAfterMs]="10000" [stalledAfterMs]="45000" slowMessage="Still importing — large files can take a minute." cancelLabel="Stop the import" (cancelled)="abortImport()" (escalated)="logSlowImport($event)" />
When to use
- Indicating an indeterminate wait where progress can't be measured.
- Inline (button, card) or as a fullscreen overlay during route/data loads.
- For content placeholders that preserve layout, use the Skeleton Loader.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| size | SpinnerSize | 'md' | Size of the spinner. |
| variant | SpinnerVariant | 'primary' | Color variant. |
| spinnerStyle | SpinnerStyle | 'border' | Visual style of the spinner. |
| label | string | 'Loading…' | Accessible label (also shown visually when labelVisible is true). |
| labelVisible | boolean | false | Show the label text visually beneath the spinner. |
| overlay | boolean | false | Fullscreen overlay mode — dims the page behind the spinner. |
| portal | boolean | true | Portal the fullscreen surface to `<body>` and make the page behind it `inert` (OF-18-I6). `false` is the opt-back to the pre-0.4 placement: the backdrop renders inside the host, still takes its z-index from the stack and still locks scroll, but the page behind it stays focusable. |
| expectedMs | number | 0 | How long this wait usually takes, in ms. `0` = unknown. AN ESCALATION IS AN APOLOGY, NOT AN EXPECTATION. `slowAfterMs` below only ever speaks once the wait has already gone wrong: it says "longer than usual" without this component ever having said what usual IS. The user's decision — wait, or reload and lose the form — is made in the first second or two, on information the host almost always has and had no way to pass on. Passing it turns a silent animation into a statement: "about 25 seconds". IT IS ALSO THE ONLY NUMBER THE HOST SHOULD HAVE TO PICK. Set this and both escalation thresholds derive from it, so the figure the user is SHOWN and the moment the component admits lateness cannot drift apart — which they did by construction when the two were separate inputs, and nothing could catch it. Off by default, like the thresholds: an expectation nobody stated is not one this component may invent. |
| expectationMessage | string | null | null | Override the generated expectation sentence — for wording, or for a language the generator does not speak. `null` = generate from expectedMs; `''` = state no expectation at all. This does NOT derive the thresholds. Text has no number in it, so a surface that wants both still sets `expectedMs` and overrides the words. |
| slowAfterMs | number | 0 | Say "this is taking longer than usual" after this many ms. `0` = never. A SPINNER IS INDISTINGUISHABLE FROM A FAILED REQUEST, FOREVER, and at ninety seconds it is not telling the truth: nothing is happening. The user's only remaining move is to reload the page and lose whatever they had typed — which is what they do. Off by default. Escalating without being asked would put new text under every spinner in every existing layout, and the honest threshold depends entirely on what is being waited for. |
| stalledAfterMs | number | 0 | Escalate to "this seems to be stuck" after this many ms. `0` = never. MEASURED FROM THE SAME START, not from `slowAfterMs`. Thirty seconds means thirty seconds into the wait. |
| slowMessage | string | 'This is taking longer than usual…' | Message shown at the `slow` stage. |
| stalledMessage | string | 'This seems to be stuck.' | Message shown at the `stalled` stage. |
| cancelLabel | string | null | null | Label for a cancel button shown once the wait is `stalled`. `null` = none. AN OUTPUT CANNOT BE ASKED WHETHER ANYONE IS LISTENING, so the offer is an input: a surface that renders "Cancel" and does nothing when it is pressed is worse than one that never offered. Set this only when `(cancelled)` is wired to something that can actually stop the work. |
| customClass | string | '' | Extra CSS classes merged onto the root wrapper. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| cancelled | void | The user pressed the cancel offer on a stalled wait. |
| escalated | SvWaitStage | Fires on each escalation: `'slow'`, then `'stalled'`. |
Customization & theming
- Four animation styles.
- Five sizes and eight colors.
- Optional fullscreen overlay mode.
- `customClass` to extend the wrapper.
- Escalation: `[slowAfterMs]` says "this is taking longer than usual", `[stalledAfterMs]` says it looks stuck and can offer a `cancelLabel` button; both messages are overridable, `(escalated)` reports each step, and both are off by default. Shared with the Skeleton Loader — `createWaitEscalation` in `@styloviz/core`.
- A stated expectation: `[expectedMs]` renders "about 25 seconds" from the first frame and derives `slow` at 1.5x and `stalled` at 4x, so one number drives the sentence and both timers; an explicit threshold still wins, `expectationMessage` overrides or blanks the wording, and `resolvedSlowAfterMs` / `resolvedStalledAfterMs` report what the component settled on. Figures round up and snap to a five-second grid above ten seconds — understating a wait is the lie that matters, and "about 23 seconds" dresses a guess up as a measurement.
Best practices
- Do: Pair the spinner with a short label for waits longer than ~1 second.
- Do: Say how long the wait usually takes with `[expectedMs]`. An escalation is an apology, not an expectation — `[slowAfterMs]` only ever speaks once the wait has already gone wrong, and says "longer than usual" without this component ever having said what usual is. The user decides whether to keep waiting in the first second or two, on information the host almost always has. Setting `[expectedMs]` also derives both thresholds, so the figure shown and the moment lateness is admitted cannot disagree.
- Don't: Don't set `[slowAfterMs]` earlier than `[expectedMs]`. The spinner would say the wait is taking longer than usual while it is still inside the time it told the user to expect — it warns in dev when the two contradict each other.
- Do: Set `[slowAfterMs]` and `[stalledAfterMs]` on anything that can hang. A spinner is indistinguishable from a failed request, and after a minute it is no longer telling the truth — the user's only remaining move is to reload and lose what they typed. Both thresholds are measured from the moment the spinner appears.
- Don't: Don't set `cancelLabel` unless `(cancelled)` is wired to something that can actually stop the work. A button that does nothing when pressed is worse than no button — the user presses it and then distrusts the whole screen.
- Don't: Don't block the whole screen for quick, local updates — use an inline spinner.
- Tip: Prefer skeletons over spinners when you can show the eventual layout.
Accessibility
- Wraps the indicator in a `role="status"` region named by its `aria-label`, and SAYS that label once, politely, through the shared `SvLiveAnnouncer` when it first renders — a status region born with its name is usually never read, so the attribute alone announced nothing. Not on the server, not for the portalled fullscreen surface (moving focus onto it already speaks the name), and escalation messages still speak from inside the region. A blank `label` falls back to "Loading…" and warns in dev mode — a status region with an empty name is announced as nothing at all.
- The fullscreen overlay is a real modal surface: it is portalled to `document.body`, registered on the shared overlay stack as blocking (so the rest of the page is `inert`, out of the tab order and the accessibility tree), locks the page scroll, moves focus onto itself (`role="dialog"` + `aria-modal`, named by the label) and restores focus when it closes. Escape does nothing while the wait cannot be cancelled, and triggers the cancel once a stalled wait offers one. `[portal]="false"` is the opt-back to the pre-0.4 in-place backdrop (still stacked and scroll-locked, but the page behind it stays focusable) for a host that queries the backdrop inside the component.
- Pair with a visible label for waits over ~1 second so the state is clear to everyone.
- The stated expectation rides in the region's accessible name rather than in its content, because a live region announces what CHANGES in it and not what it was born with — so the visible copy is `aria-hidden` and the wait is described once, not twice. That name does not change when the wait escalates; the escalation speaks for itself.
- An escalation message is announced from that same region rather than a second one — the region already exists when the text appears, which is what makes the change audible at all. The cancel offer appears only at the stalled stage, so a wait that is merely slow does not invite the user to abandon work that was about to succeed.
Feedback on Spinner
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.