Spinner
A loading indicator in six looks — sizes xs–xl, colour-inheriting, with a LoadingOverlay, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Spinner signals indeterminate loading. It inherits the current text
colour (recolour with a text-* class), comes in five sizes, and announces
itself to assistive tech. LoadingOverlay pairs it with a blurred scrim to cover
a card or the whole screen while it loads. In Angular they're <bpdm-spinner>
and <bpdm-loading-overlay>.
Usage
import { Spinner } from '@bpdm/ui/spinner';
export function SpinnerUsage() {
return <Spinner size="lg" />;
}import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';
@Component({
selector: 'spinner-usage',
imports: [BpdmSpinner],
templateUrl: './spinner-usage.html',
})
export class SpinnerUsage {}<bpdm-spinner size="lg" />Variants
Six looks: ring (default), gradient, square, dots, bars, and flip.
import { Spinner } from '@bpdm/ui/spinner';
export function SpinnerVariants() {
return (
<>
<Spinner variant="ring" size="lg" />
<Spinner variant="gradient" size="lg" />
<Spinner variant="square" size="lg" />
<Spinner variant="dots" size="lg" />
<Spinner variant="bars" size="lg" />
<Spinner variant="flip" size="lg" />
</>
);
}import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';
@Component({
selector: 'spinner-variants',
imports: [BpdmSpinner],
templateUrl: './spinner-variants.html',
})
export class SpinnerVariants {}<bpdm-spinner variant="ring" size="lg" />
<bpdm-spinner variant="gradient" size="lg" />
<bpdm-spinner variant="square" size="lg" />
<bpdm-spinner variant="dots" size="lg" />
<bpdm-spinner variant="bars" size="lg" />
<bpdm-spinner variant="flip" size="lg" />Sizes
Five sizes: xs, sm, md (default), lg, and xl.
import { Spinner } from '@bpdm/ui/spinner';
export function SpinnerSizes() {
return (
<>
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
<Spinner size="xl" />
</>
);
}import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';
@Component({
selector: 'spinner-sizes',
imports: [BpdmSpinner],
templateUrl: './spinner-sizes.html',
})
export class SpinnerSizes {}<bpdm-spinner size="xs" />
<bpdm-spinner size="sm" />
<bpdm-spinner size="md" />
<bpdm-spinner size="lg" />
<bpdm-spinner size="xl" />Colour
The spinner draws in the current text colour, so a text-* class recolours it —
no variant prop needed.
import { Spinner } from '@bpdm/ui/spinner';
export function SpinnerColours() {
return (
<>
<Spinner size="lg" className="text-primary" />
<Spinner size="lg" className="text-success" />
<Spinner size="lg" className="text-warning" />
<Spinner size="lg" className="text-destructive" />
<Spinner size="lg" className="text-muted-foreground" />
</>
);
}import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';
@Component({
selector: 'spinner-colour',
imports: [BpdmSpinner],
templateUrl: './spinner-colour.html',
})
export class SpinnerColours {}<bpdm-spinner size="lg" class="text-primary" />
<bpdm-spinner size="lg" class="text-success" />
<bpdm-spinner size="lg" class="text-warning" />
<bpdm-spinner size="lg" class="text-destructive" />
<bpdm-spinner size="lg" class="text-muted-foreground" />Inline
Drop a small spinner inside a button or beside text — it inherits that element's
colour via text-current.
import { Spinner } from '@bpdm/ui/spinner';
import { Button } from '@bpdm/ui/button';
export function InlineSpinners() {
return (
<>
<Button disabled>
<Spinner size="sm" className="text-current" />
Saving…
</Button>
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" variant="dots" className="text-current" />
Loading results
</span>
</>
);
}import { Component } from '@angular/core';
import { BpdmSpinner, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'inline-spinners',
imports: [BpdmSpinner, BpdmButton],
templateUrl: './spinner-inline.html',
})
export class InlineSpinners {}<button bpdmButton disabled>
<bpdm-spinner size="sm" class="text-current" />
Saving…
</button>
<span class="inline-flex items-center gap-2 text-sm text-muted-foreground">
<bpdm-spinner size="sm" variant="dots" class="text-current" />
Loading results
</span>Inline value
Swap a single value for a small spinner while it refetches — reserve the row height so the layout doesn't jump when the spinner and value trade places.
Active users
+8.2% this week
import { useState } from 'react';
import { Spinner } from '@bpdm/ui/spinner';
import { Button } from '@bpdm/ui/button';
export function ActiveUsers() {
const [loading, setLoading] = useState(false);
const refetch = () => {
setLoading(true);
setTimeout(() => setLoading(false), 1600);
};
return (
<div className="w-72 space-y-3">
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="text-sm text-muted-foreground">Active users</p>
{/* reserve the height so swapping spinner ↔ value doesn't shift layout */}
<div className="mt-1 flex h-8 items-center">
{loading ? (
<Spinner size="sm" variant="dots" className="text-muted-foreground" />
) : (
<span className="text-2xl font-semibold tabular-nums">12,480</span>
)}
</div>
<p className="mt-1 text-xs text-success">+8.2% this week</p>
</div>
<Button size="sm" variant="secondary" appearance="outline" onClick={refetch}>
Refetch amount
</Button>
</div>
);
}import { Component, signal } from '@angular/core';
import { BpdmSpinner, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'active-users',
imports: [BpdmSpinner, BpdmButton],
templateUrl: './spinner-inline-value.html',
})
export class ActiveUsers {
readonly loading = signal(false);
refetch() {
this.loading.set(true);
setTimeout(() => this.loading.set(false), 1600);
}
}<div class="w-72 space-y-3">
<div class="rounded-xl border bg-card p-5 shadow-sm">
<p class="text-sm text-muted-foreground">Active users</p>
<!-- reserve the height so swapping spinner ↔ value doesn't shift layout -->
<div class="mt-1 flex h-8 items-center">
@if (loading()) {
<bpdm-spinner size="sm" variant="dots" class="text-muted-foreground" />
} @else {
<span class="text-2xl font-semibold tabular-nums">12,480</span>
}
</div>
<p class="mt-1 text-xs text-success">+8.2% this week</p>
</div>
<button bpdmButton size="sm" variant="secondary" appearance="outline" (click)="refetch()">
Refetch amount
</button>
</div>Loading overlay
LoadingOverlay covers its nearest positioned ancestor (give it relative) with
a blurred scrim and a centred spinner. Set fullPage to cover the viewport
instead. Click Refresh below to see it scoped to the card.
Active users
12,480
+8.2% this week
import { useState } from 'react';
import { LoadingOverlay } from '@bpdm/ui/spinner';
import { Button } from '@bpdm/ui/button';
export function Card() {
const [loading, setLoading] = useState(false);
const refetch = () => {
setLoading(true);
setTimeout(() => setLoading(false), 1800);
};
return (
<div className="space-y-3">
<div className="relative overflow-hidden rounded-xl border bg-card p-5">
<p className="text-sm text-muted-foreground">Active users</p>
<p className="mt-1 text-2xl font-semibold">12,480</p>
<LoadingOverlay show={loading} label="Fetching…" />
</div>
<Button size="sm" variant="secondary" appearance="outline" onClick={refetch}>
Refresh
</Button>
</div>
);
}import { Component, signal } from '@angular/core';
import { BpdmLoadingOverlay, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'overlay-card',
imports: [BpdmLoadingOverlay, BpdmButton],
templateUrl: './loading-overlay.html',
})
export class OverlayCard {
readonly loading = signal(false);
refetch() {
this.loading.set(true);
setTimeout(() => this.loading.set(false), 1800);
}
}<div class="relative overflow-hidden rounded-xl border bg-card p-5">
<p class="text-sm text-muted-foreground">Active users</p>
<p class="mt-1 text-2xl font-semibold">12,480</p>
<bpdm-loading-overlay [show]="loading()" label="Fetching…" />
</div>
<button bpdmButton (click)="refetch()">Refresh</button>
<!-- whole screen instead of a card -->
<bpdm-loading-overlay [show]="loading()" fullPage label="Loading…" />Internationalization
The spinner announces itself to screen readers through a visually-hidden label — it has no other copy of its own. You set that label in one of three places, in order of precedence:
label(per instance) — a string on a single<Spinner>/<LoadingOverlay>. Best for context-specific announcements ("Loading results", "Saving").messages.loading— the app-wide default label, set via a singlemessagesobject. It's aPartial<SpinnerMessages>merged over the English default, so a spinner with nolabelannounces this instead.- The built-in default —
"Loading"— used when neither is set.
A string label always wins over messages.loading, which wins over the built-in
default. Any visible overlay text (LoadingOverlay's label) is your own copy
— you translate it like the rest of the content you pass in.
- React — pass
messagesto<Spinner messages={{ loading: '…' }} />or<LoadingOverlay messages={{ loading: '…' }} />. - Angular — bind
[messages]on<bpdm-spinner>and<bpdm-loading-overlay>.
The spinner is direction-neutral: its animated shape is rotationally symmetric,
so it mirrors cleanly under dir="rtl" with no extra prop. An inline spinner beside
text, and the overlay's spinner-over-message stack, both follow the writing
direction automatically.
The example below sets the app-wide default label via messages — only that short
built-in string is localised; any visible message you pass stays your own copy:
import { Spinner, LoadingOverlay, type SpinnerMessages } from '@bpdm/ui/spinner';
// Sets the app-wide default sr-only label used when no `label` is passed.
const messages: Partial<SpinnerMessages> = {
loading: 'Wird geladen',
};
export function LoadingStates() {
return (
<>
{/* uses messages.loading → announces "Wird geladen" */}
<Spinner size="lg" messages={messages} />
{/* a per-instance label still wins over messages.loading */}
<Spinner size="sm" variant="dots" label="Loading results" messages={messages} />
<div className="relative h-24 rounded-xl border">
<LoadingOverlay show messages={messages} />
</div>
</>
);
}import { Component } from '@angular/core';
import { BpdmSpinner, BpdmLoadingOverlay, type SpinnerMessages } from '@bpdm/ng';
@Component({
selector: 'loading-states',
imports: [BpdmSpinner, BpdmLoadingOverlay],
templateUrl: './loading-states.html',
})
export class LoadingStates {
// Sets the app-wide default sr-only label used when no `label` is passed.
readonly messages: Partial<SpinnerMessages> = {
loading: 'Wird geladen',
};
}<!-- uses messages.loading → announces "Wird geladen" -->
<bpdm-spinner size="lg" [messages]="messages" />
<!-- a per-instance label still wins over messages.loading -->
<bpdm-spinner size="sm" variant="dots" label="Loading results" [messages]="messages" />
<div class="relative h-24 rounded-xl border">
<bpdm-loading-overlay [show]="true" [messages]="messages" />
</div>API
Spinner / <bpdm-spinner>
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ring | gradient | square | dots | bars | flip | ring | Visual style. Angular: [variant]. |
size | xs | sm | md | lg | xl | md | Spinner size. Angular: [size]. |
label | string | messages.loading | Visually-hidden accessible label for this instance — wins over messages.loading. Angular: [label]. |
messages | Partial<SpinnerMessages> | English | Override the app-wide default sr-only label (see below). Angular: [messages]. |
className | string | — | Extra classes; a text-* class recolours the spinner. Angular: class. |
Recolour with any text-* class — the spinner inherits currentColor. In React
all other native <span> attributes are forwarded onto the root.
LoadingOverlay / <bpdm-loading-overlay>
| Prop | Type | Default | Description |
|---|---|---|---|
show | boolean | true | Render the overlay. Angular: [show]. |
label | string | — | Visible message shown under the spinner; also names the inner spinner for screen readers. Angular: [label]. |
variant | SpinnerVariant | ring | Inner spinner style. Angular: [variant]. |
size | SpinnerSize | md (lg if fullPage) | Inner spinner size. Angular: [size]. |
fullPage | boolean | false | Cover the viewport instead of the nearest positioned ancestor. Angular: [fullPage]. |
blur | boolean | true | Blur the content behind the scrim. Angular: [blur]. |
messages | Partial<SpinnerMessages> | English | Override the inner spinner's default sr-only label when no label is set (see below). Angular: [messages]. |
className | string | — | React — extra classes on the overlay root. Angular: class. |
Give the overlay's nearest ancestor relative so it's scoped to that box (or set
fullPage to cover the viewport). In React all other native <div> attributes are
forwarded onto the root.
App-wide copy — messages
Set the spinner's default screen-reader label in one place (see
Internationalization). It's a Partial<SpinnerMessages>
merged over the English default — a per-instance label string still wins over it.
- React — the
messagesprop on<Spinner>and<LoadingOverlay>. - Angular — the
[messages]input on<bpdm-spinner>and<bpdm-loading-overlay>.
SpinnerMessages key | Default | Description |
|---|---|---|
loading | Loading | Visually-hidden accessible label announced while loading, used when no per-instance label is passed. |
Accessibility
- The spinner is a live region —
role="status"witharia-live="polite"— so assistive tech announces the loading state when it appears, without interrupting. - It carries a visually-hidden label (the per-instance
label, elsemessages.loading, else the built-in"Loading"), so the loading state is conveyed as text — never by motion or colour alone. - The animated shape itself is decorative; the accessible name comes from that
hidden label, so set a meaningful
labelfor context (e.g. "Loading results", "Saving"). LoadingOverlayaddsaria-busy="true"while shown and covers its nearest positioned ancestor — or the whole viewport withfullPage. Its visiblelabeldoubles as the inner spinner's accessible name.- Everything is direction-neutral under
dir="rtl": the animated shape is rotationally symmetric, and the inline spinner-plus-text and the overlay's spinner-over-message stack follow the writing direction. - The animation respects
prefers-reduced-motion.