<bpdm/ui />
Misc

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

Loading
spinner-usage.tsx
import { Spinner } from '@bpdm/ui/spinner';

export function SpinnerUsage() {
  return <Spinner size="lg" />;
}
spinner-usage.ts
import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';

@Component({
  selector: 'spinner-usage',
  imports: [BpdmSpinner],
  templateUrl: './spinner-usage.html',
})
export class SpinnerUsage {}
spinner-usage.html
<bpdm-spinner size="lg" />

Variants

Six looks: ring (default), gradient, square, dots, bars, and flip.

Loadingring
Loadinggradient
Loadingsquare
Loadingdots
Loadingbars
Loadingflip
spinner-variants.tsx
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" />
    </>
  );
}
spinner-variants.ts
import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';

@Component({
  selector: 'spinner-variants',
  imports: [BpdmSpinner],
  templateUrl: './spinner-variants.html',
})
export class SpinnerVariants {}
spinner-variants.html
<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.

Loadingxs
Loadingsm
Loadingmd
Loadinglg
Loadingxl
spinner-sizes.tsx
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" />
    </>
  );
}
spinner-sizes.ts
import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';

@Component({
  selector: 'spinner-sizes',
  imports: [BpdmSpinner],
  templateUrl: './spinner-sizes.html',
})
export class SpinnerSizes {}
spinner-sizes.html
<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.

Loadingprimary
Loadingsuccess
Loadingwarning
Loadingdestructive
Loadingmuted
spinner-colour.tsx
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" />
    </>
  );
}
spinner-colour.ts
import { Component } from '@angular/core';
import { BpdmSpinner } from '@bpdm/ng';

@Component({
  selector: 'spinner-colour',
  imports: [BpdmSpinner],
  templateUrl: './spinner-colour.html',
})
export class SpinnerColours {}
spinner-colour.html
<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.

LoadingLoading results
spinner-inline.tsx
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>
    </>
  );
}
spinner-inline.ts
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 {}
spinner-inline.html
<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

12,480

+8.2% this week

spinner-inline-value.tsx
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>
  );
}
spinner-inline-value.ts
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);
  }
}
spinner-inline-value.html
<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

loading-overlay.tsx
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>
  );
}
loading-overlay.ts
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);
  }
}
loading-overlay.html
<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:

  1. label (per instance) — a string on a single <Spinner> / <LoadingOverlay>. Best for context-specific announcements ("Loading results", "Saving").
  2. messages.loading — the app-wide default label, set via a single messages object. It's a Partial<SpinnerMessages> merged over the English default, so a spinner with no label announces this instead.
  3. 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 messages to <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:

spinner-i18n.tsx
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>
    </>
  );
}
loading-states.ts
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',
  };
}
loading-states.html
<!-- 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>

PropTypeDefaultDescription
variantring | gradient | square | dots | bars | flipringVisual style. Angular: [variant].
sizexs | sm | md | lg | xlmdSpinner size. Angular: [size].
labelstringmessages.loadingVisually-hidden accessible label for this instance — wins over messages.loading. Angular: [label].
messagesPartial<SpinnerMessages>EnglishOverride the app-wide default sr-only label (see below). Angular: [messages].
classNamestringExtra 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>

PropTypeDefaultDescription
showbooleantrueRender the overlay. Angular: [show].
labelstringVisible message shown under the spinner; also names the inner spinner for screen readers. Angular: [label].
variantSpinnerVariantringInner spinner style. Angular: [variant].
sizeSpinnerSizemd (lg if fullPage)Inner spinner size. Angular: [size].
fullPagebooleanfalseCover the viewport instead of the nearest positioned ancestor. Angular: [fullPage].
blurbooleantrueBlur the content behind the scrim. Angular: [blur].
messagesPartial<SpinnerMessages>EnglishOverride the inner spinner's default sr-only label when no label is set (see below). Angular: [messages].
classNamestringReact — 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 messages prop on <Spinner> and <LoadingOverlay>.
  • Angular — the [messages] input on <bpdm-spinner> and <bpdm-loading-overlay>.
SpinnerMessages keyDefaultDescription
loadingLoadingVisually-hidden accessible label announced while loading, used when no per-instance label is passed.

Accessibility

  • The spinner is a live regionrole="status" with aria-live="polite" — so assistive tech announces the loading state when it appears, without interrupting.
  • It carries a visually-hidden label (the per-instance label, else messages.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 label for context (e.g. "Loading results", "Saving").
  • LoadingOverlay adds aria-busy="true" while shown and covers its nearest positioned ancestor — or the whole viewport with fullPage. Its visible label doubles 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.

On this page