Design system

Spinner

An eight-dot loading indicator in primary blue, for waits where you cannot show the shape of the result.

import { Spinner } from "@/ui";

Spinner is the one loading indicator for the app. Its source asks that it replace lucide Loader2 and Phosphor CircleNotch, but about 30 files outside _archive still use one of those, so you will see both for now.

Five Goals files (GoalDetailPage, GoalQuickView, RecommendationDrawer, RunHistoryPage, SageWidget) declare their own const Spinner that is a spinning CircleNotch, and PublishView imports Phosphor's Spinner icon. Those are not this component, even though the name matches. The real one is used in RunsActivity and the skills-v2 run screens.

Default

Eight dots orbit a centre point and fade in turn, one cycle every 1.1 seconds. The default size is 20px.

<Spinner />

Sizes

size is the diameter in pixels. Any number works; these are the sizes the app uses.

<Spinner size={13} />
<Spinner size={16} />
<Spinner size={20} />
<Spinner size={24} />
<Spinner size={28} />
SizeWhere it is used
13pxBeside 12px text: running counts and step progress in RunsActivity and ExecutionProgress.
16pxIn place of a 16px status icon in a progress row.
20px (default)Inline before a sentence, such as "Loading plan…" in PlanApprovalCard.
24px and 28pxCentred in an empty panel or a redirect dialog while the whole view loads.

Inline with text

Put the spinner before a short label that says what is loading. Align them with flex and an 8px gap.

<div className="flex items-center gap-2 text-[14px] text-[var(--color-text-secondary)]">
  <Spinner size={16} /> Loading campaigns…
</div>
<div className="flex items-center gap-2 text-[12px] text-[var(--color-text-secondary)]">
  <Spinner size={13} /> 3 workflows running
</div>

In a button

Spinner accepts and ignores a weight prop, so you can pass the component itself as a Button icon. Button sizes it to match. Click to try it.

<Button
  variant="primary"
  label={busy ? "Syncing HubSpot" : "Sync HubSpot"}
  icon={busy ? Spinner : undefined}
  disabled={busy}
  onClick={sync}
/>

This is how BlockedCallout shows "Starting…" on its run buttons. Keep the button disabled while it is busy: a disabled button turns grey (--color-grey-100), which is what makes the blue dots visible on a primary button. On an enabled primary button they would disappear into the blue fill.

Colour

The colours are fixed in Spinner.css: the leading dot is #3661ED (--color-primary-500) and the trail fades through 70%, 50% and 20% of it. They are hard-coded rather than tokens, and they ignore color, so classes such as text-[var(--accent)] have no effect.

Props

PropTypeDefaultNotes
sizenumber20Diameter in px.
classNamestring""For layout only (margins, alignment).
styleCSSPropertiesMerged after the computed width and height.
weightanyIgnored. It exists so Spinner can stand in for a Phosphor icon.

Any other prop (aria-label, data-*) is passed to the outer <span>.

Accessibility

  • The outer span has role="status" and aria-label="Loading". Pass your own aria-label when the context is not obvious, such as "Loading campaigns".
  • When visible text already says what is loading, the label is read twice. You can pass aria-hidden to the Spinner in that case.
  • With reduced motion turned on, the animation slows to 2.2 seconds a cycle rather than stopping.

Do and don't

  • Do use Spinner for waits under a few seconds, or when you do not know the shape of what is coming: a sync, a run, a chat reply.
  • Do say what is loading next to it: "Loading campaigns…", not just a spinner.
  • Do use a Skeleton instead when you know the layout of the result, such as a table or a list of cards.
  • Don't add animate-spin or colour classes.
  • Don't name a local component Spinner. It hides the real one and confuses search.
  • Don't put more than one spinner in view for the same wait.

On this page