Components · Feedback indicators

Spinner

A rotating arc for indeterminate loading. Use it when something is happening but you can't quote the user a percentage — saving a form, fetching a list, processing a payment.

Documentedby Derek Fidler
Loading

Default

Overview

The Spinner ships from @flatpay-dk/ui as a single SVG arc that rotates in currentColor. One geometry, five sizes — the spinner is the indeterminate twin of the progress bar. Reach for it whenever the system can’t quote a percentage; reach for Progress bar when it can.

Reach for it sparingly

A spinner is an apology. Every time one appears, the user is waiting on us. Optimistic UI, skeleton states, and instant local updates beat a spinner — the spinner is what’s left when they’re not options.

Sizes

Five sizes, picked to align with the type and component scale. The stroke weight scales with the size so the arc reads consistently weighty at every step — thinner doesn’t mean wirier.

  • size="xs"12 px

    Inline beside small body text.

    Loading xs
  • size="sm"16 px

    Default for inline status, button labels.

    Loading sm
  • size="md"20 px

    Stand-alone in dense rows or cells.

    Loading md
  • size="lg"32 px

    Card-level loading skeletons.

    Loading lg
  • size="xl"48 px

    Whole-surface spinners — page or modal load.

    Loading xl

Colour inheritance

The spinner inherits from currentColor, so it flips with whatever foreground the surrounding container sets. No colour prop, no theme switch — set the parent text colour and the spinner follows.

On bg-card · charcoal
On bg-foreground · off-white
On rose tint · status colour

Patterns

Three places the spinner shows up most. Each one has a different accessibility story — picking the right pattern picks the right announcement.

In a button

The Button component handles this for you — pass loading and the label is swapped for a spinner without reflow. The button itself carries aria-busy; the spinner stays decorative.

Click any button — they all hold the spinner for 1.5 s.

Inline beside a label

Stand-alone, beside text that names what’s happening. Pass label={null} on the spinner so the surrounding sentence does the announcing.

Syncing manifest…

Building preview deployment

Authorising payment

Whole-surface load

For a section or page that’s waiting on its first render. Centre the spinner with a single line of context underneath; never blank the surface entirely.

Loading prototypes…

Motion

One full rotation per second, linear easing — the standard indeterminate cadence. Under prefers-reduced-motion, rotation slows to four seconds rather than stopping. A still spinner reads as broken; a slow spinner reads as patient. Per the Motion foundation, this is the one place transforms aren’t cut entirely under reduced motion — they’re calmed instead.

Accessibility

  • Default label.The spinner ships with a visually-hidden “Loading” announced via role="status" + aria-live=“polite”. Override with the labelprop when you can name the operation more specifically — “Saving draft”, “Authorising payment”.
  • Decorative mode. Pass label={null} when the spinner sits alongside text or inside a control that already announces the busy state. Two announcements is one too many.
  • Reduced motion.Honoured automatically — you don’t need to gate it in product code.
  • No infinite spinners.If a spin lasts longer than ~10 s, swap to a progress message that names what’s taking so long, or surface a retry. A spinner that never resolves is the second-worst loading experience after a frozen page.

Code

tsx

import { Spinner } from "@flatpay-dk/ui";

// Default — md size, "Loading" SR label
<Spinner />

// Sized
<Spinner size="xs" />
<Spinner size="xl" />

// Custom screen-reader label
<Spinner label="Saving draft" />

// Decorative — when the surrounding text already names the operation
<p className="flex items-center gap-2 text-sm">
  <Spinner size="xs" label={null} />
  Syncing manifest…
</p>

// Inside a Button — pass `loading` to the Button itself; it draws the spinner for you
<Button loading={isSaving}>Save draft</Button>

// On a coloured surface — currentColor follows the parent
<div className="bg-foreground text-background p-6">
  <Spinner size="lg" />
</div>

Best practices

  • Indeterminate only. When you know what percentage is done, use Progress bar. The spinner is the “we don’t know yet” affordance.
  • Skip it under 200 ms.A spinner that flashes for less time than a heartbeat reads as a glitch. If the operation usually finishes that fast, don’t mount the spinner; just block the UI until it settles.
  • Pair with context.“Loading” is fine on a known surface; on a slower or rarer operation, name the verb — “Authorising payment”, “Pulling manifest from GitHub”.
  • One spinner per surface.A page with three spinners isn’t loading harder; it’s loading louder. Either consolidate to one spinner over the whole section, or replace with skeleton states that hint at the shape coming in.

Props

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"12 / 16 / 20 / 32 / 48 px. Stroke weight scales with size so the arc reads consistently weighty.
labelstring | null"Loading"Visually-hidden screen-reader label. Pass null to mark the spinner decorative when the surrounding context already announces the busy state.
classNamestringForwarded to the wrapping <span>. Useful for layout (flex / margin) — colour rides on currentColor.