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} />| Size | Where it is used |
|---|---|
| 13px | Beside 12px text: running counts and step progress in RunsActivity and ExecutionProgress. |
| 16px | In place of a 16px status icon in a progress row. |
| 20px (default) | Inline before a sentence, such as "Loading plan…" in PlanApprovalCard. |
| 24px and 28px | Centred 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
| Prop | Type | Default | Notes |
|---|---|---|---|
size | number | 20 | Diameter in px. |
className | string | "" | For layout only (margins, alignment). |
style | CSSProperties | Merged after the computed width and height. | |
weight | any | Ignored. 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"andaria-label="Loading". Pass your ownaria-labelwhen 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-hiddento 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-spinor 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.