React Loading Spinner: 3 Instances, and 3 More Places That Skip It
Three components spin a Loader2 icon; three more swap button text instead and skip it entirely. The rule that decides between them, audited across this codebase's seven loading states.
A React loading spinner is usually a Loader2-style icon from a library like lucide-react, spun with a CSS animation and shown while an async call is in flight. This codebase has exactly three of them — and three other pending states that deliberately use no spinner at all. The split isn't random: it tracks whether the wait is unknown-length and blocking, or short and predictable.
The short answer
Render a spinner icon (animate-spin plus aria-busy="true") for an async operation of unknown duration that blocks the next screen — a checkout URL resolving, a modal's iframe loading. For a form submission whose only visible effect is a button's own label, swap the button's text instead ("Saving…") and skip the icon; the disabled state already says "busy." For a hydration gap under one render tick, show neutral static text and nothing else — a spinner that flashes for 16ms reads as jank, not feedback.
The census
Three files carry an actual spinning icon:
3 × <Loader2 className="... animate-spin" aria-hidden="true" />
BuyButton.tsx h-4 w-4, inside a <Button>, paired with aria-busy="true"
PurchaseCta.tsx h-4 w-4, inside a <span> styled as a button, same aria-busy
PreviewModal.tsx h-6 w-6, alone in an absolute-positioned overlay, no aria-busy
Three more files show a pending state with no icon at all:
ContactForm.tsx disabled + "Sending…" swapped in for the label
SettingsForm.tsx disabled + "Saving…" (or a custom pendingLabel)
RefundButton.tsx no button at all — a static line of text: "Refund requested…"
And one file explicitly rejects a spinner in code comments:
// src/components/organisms/SavedTemplatesGrid.tsx
{!hydrated ? (
// Neutral placeholder, not a spinner: hydration is a single tick, and
// a spinner that flashes for 16ms reads as jank.
<p className="py-20 text-center text-base text-text-color-secondary">
Loading your saved templates…
</p>
) : /* ... */}
Seven loading states, two icons wide, and the pattern that decides between them has nothing to do with which component happens to need one.
The rule: what is the user waiting for, and do they know it
BuyButton is the clearest case for a real spinner. On mount it has to resolve three unknowns before it can render its real label — is there a session, does the buyer already own this tier, what's the LemonSqueezy checkout URL:
// src/components/molecules/BuyButton.tsx
const [loading, setLoading] = useState(configured);
// ...
if (loading) {
return (
<Button variant={variant} disabled aria-busy="true" className={`${className} opacity-60`}>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading…
</Button>
);
}
Three things distinguish this from the text-swap cases below. First, the duration is unknown — it depends on a network round trip to Supabase and then to a Server Action, not a fixed animation length. Second, the button's entire identity changes underneath the spinner: it might become "Buy now", "Sign in to buy", or "Owned — view in dashboard" once loading resolves, so there's no single word to swap in the way "Saving…" swaps for "Save changes". Third, disabled plus opacity-60 alone reads as broken on a network that hasn't hung — a disabled button with no explanation looks like a bug, and the icon supplies the explanation aria-busy="true" gives to assistive tech but nothing conveys visually on its own.
PreviewModal makes the same call for a different reason — it isn't a button at all, it's a whole iframe:
// src/components/molecules/PreviewModal.tsx
{!loaded && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-white dark:bg-gray-900">
<Loader2 className="h-6 w-6 animate-spin text-primary" aria-hidden="true" />
<p className="text-sm text-text-color-secondary dark:text-gray-400">
Loading preview…
</p>
</div>
)}
There's no label to swap here — the thing loading is a live embedded site, and until it fires its onLoad there is nothing on screen but blank white. A spinner over an empty frame is the one case in this codebase where the icon is the only available signal that anything is happening at all, which is also why it's the only one sized h-6 w-6 instead of h-4 w-4 — it's carrying the whole loading message, paired with its own line of text, rather than riding next to a label.
The counter-pattern: forms already have a word to swap
ContactForm and SettingsForm both submit through a Server Action and track pending from useTransition, and neither reaches for Loader2:
// src/components/molecules/ContactForm.tsx
<button disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
// src/components/molecules/SettingsForm.tsx
<button disabled={pending || !canSubmit}>
{pending ? (pendingLabel ?? "Saving…") : submitLabel}
</button>
The difference from BuyButton isn't the mechanism — both are async, both are unknown-duration. It's that a form submission has an obvious resting word ("Send message") with an obvious busy word ("Sending…") sitting right next to it, so the busy state doesn't need a second visual channel to explain itself. Adding a spinner icon here would be decoration on top of information that's already legible — the disabled opacity plus the verb-tense change already tells the whole story, and a spinning icon next to changed text is two signals for one fact.
RefundButton goes one step further and drops the button too:
// src/components/molecules/RefundButton.tsx
if (pending) {
return (
<p className="text-xs font-medium text-text-color-secondary dark:text-gray-400">
Refund requested — we'll email you.
</p>
);
}
Once a refund request is filed there's nothing left to click and nothing left to wait for on this page — the actual processing happens server-side, asynchronously, outside this render entirely. So this isn't really a loading state at all; it's a completed-state message that happens to render in the same pending branch. Reaching for a spinner would imply the browser is waiting on something, when what actually happened is the browser is done and a human is now in the loop.
Comparison: which loading pattern for which wait
| Pattern | Where | Duration is | The label changes to | Icon? |
|---|---|---|---|---|
Icon + aria-busy | BuyButton, PurchaseCta | Unknown (network + auth) | An entirely different control | Yes, h-4 w-4 |
| Icon alone, no label swap | PreviewModal | Unknown, nothing else on screen | N/A — it's the only content | Yes, h-6 w-6 |
| Disabled + text swap | ContactForm, SettingsForm | Unknown, but a verb pair exists | "Send message" → "Sending…" | No |
| Static replacement text | RefundButton | Already resolved server-side | Button → a sentence | No |
| Neutral placeholder | SavedTemplatesGrid | One render tick (hydration) | N/A — nothing to swap yet | No |
The row that's easy to miss: two of the five patterns use the same mechanism (Loader2 + animate-spin) for two different reasons — one because the button's whole identity is unresolved, one because there's no label at all to lean on. Neither is "the site's spinner component" reused for convenience; each is the smallest signal that fully explains its own wait.
Building the spinner icon itself
None of these hand-roll the SVG. Loader2 ships from lucide-react (already a runtime dependency here for every icon on the site) and Tailwind's animate-spin utility does the rotation — a 1s linear infinite keyframe defined once in Tailwind's own preflight, applied by one class:
import { Loader2 } from "lucide-react";
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
aria-hidden="true" on the icon matters as much as animate-spin does: the icon is decorative, and the actual state announcement is aria-busy="true" on the parent control, not the glyph. A screen reader that read out "loader two, rotating" on every busy button would be worse than saying nothing.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| A spinner flashes for a frame, then vanishes | Showing it for a render tick that resolves synchronously (hydration, a cached value) | Use a static placeholder instead — SavedTemplatesGrid's pattern |
| Screen reader users get no feedback during a load | animate-spin with no aria-busy on the interactive parent | Set aria-busy="true" on the button/control the spinner lives inside |
| The spinner announces itself twice to assistive tech | Missing aria-hidden="true" on the icon itself | Hide the glyph; let aria-busy on the parent carry the semantics |
| A disabled button with no spinner looks broken, not busy | Text never changes and there's no icon | Either swap the label ("Saving…") or add the icon — never leave a disabled control unexplained |
| Two loading signals fight for attention on one small control | An icon and a text swap on the same tight-fitting button | Pick one — icon when there's no label to lean on, text swap when there is |
| The spinner keeps spinning after the async call actually finished | State update guarded by a stale closure or a missed cancelled flag | See BuyButton's let cancelled = false pattern in its useEffect cleanup |
Frequently asked questions
Should every async button in React show a spinner?
No. If the button already has a resting label and a busy label that make sense side by side ("Save" / "Saving…"), the text swap plus disabled is enough — that's three of the seven loading states in this codebase. Reach for an icon when the control's whole identity is unresolved, or when there's no label to swap in the first place.
Where should the spinner icon come from — a custom SVG or a library?
A library icon set you already depend on, if one exists. This project ships lucide-react for every icon on the site, so Loader2 costs nothing marginal to add; hand-rolling a spin keyframe and SVG here would duplicate what animate-spin and an existing dependency already provide for free.
Does the spinner need its own accessible label?
The icon itself should be aria-hidden="true" — it's decorative. The accessible signal is aria-busy="true" on the interactive element the spinner sits inside, which is what actually tells assistive tech the control is mid-operation.
Is a spinner ever wrong even for a genuinely unknown-length wait?
Yes — when nothing is left to wait for from the browser's point of view. RefundButton's pending state fires after the request has already been filed; the wait that follows is a human checking email, not a network call this render can track, so a static sentence replaces the button instead of spinning indefinitely for a process this code has no way to observe finishing.
Templates in this post
ASoc Reach markets an AI marketing agency with a performance-analytics hero and an eight-item AI services grid — ASoc Realm is a property-management SaaS site built around an occupancy-metrics hero and a 50+ integrations grid, and ASoc Relay markets a team-messaging platform with a live chat-widget hero and omnichannel solution blocks — all three built on the same component conventions audited above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
