Browse docs
Browse docs
Show that something's loading. Don't flash for fast loads.
import { Spinner } from '@dashforge/tw';
<Spinner /><Spinner /> {/* default md, currentColor */}
<Spinner size="lg" color="primary" /> {/* large, indigo */}
<Spinner withTrack thickness="thick" /> {/* on busy bg */}
<Spinner delay={200} /> {/* anti-flash: only shows after 200ms */}Five-step scale, mapping to Tailwind w-*/h-* classes:
import { Spinner, Stack } from '@dashforge/tw';
<Stack direction="row" gap={4} align="center">
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
<Spinner size="xl" />
</Stack>Inherit currentColor (default) or pass an explicit semantic color:
import { Spinner, Stack } from '@dashforge/tw';
<Stack direction="row" gap={4} align="center">
<Spinner color="primary" />
<Spinner color="secondary" />
<Spinner color="success" />
<Spinner color="warning" />
<Spinner color="danger" />
<Spinner color="info" />
<Spinner color="neutral" />
</Stack>Inherit mode pairs naturally with text-color contexts (e.g. inside a button label). Explicit mode is for standalone callouts.
Three thickness steps (thin / md / thick) plus the opt-in withTrack ghost ring at 20% opacity of currentColor (useful on busy backgrounds):
import { Spinner, Stack } from '@dashforge/tw';
<Stack direction="row" gap={4} align="center">
<Spinner size="lg" color="primary" thickness="thin" />
<Spinner size="lg" color="primary" thickness="md" />
<Spinner size="lg" color="primary" thickness="thick" />
<Spinner size="lg" color="primary" withTrack />
</Stack>For state changes where the loading window is often under 200ms (e.g. cached fetches), set delay to defer rendering. If the loading state resolves before the delay, the spinner never appears — no flicker.
<Spinner delay={200} /> {/* only renders after 200ms of loading */}Pair with a loading state boolean:
function Refresh() {
const [loading, setLoading] = useState(false);
return (
<Button onClick={async () => {
setLoading(true);
await refetch();
setLoading(false);
}}>
Refresh {loading && <Spinner size="sm" delay={150} />}
</Button>
);
}If refetch() completes in under 150 ms, the user never sees the spinner — even though the React tree mounted it.
<Button> and <IconButton>'s loading prop now delegate to <Spinner> internally — no more two bespoke spinner glyphs in the catalog. You don't need to manually inject one:
<Button loading={isSaving}>Save</Button>
{/* Button internally renders <Spinner size="..." /> in the label slot */}If you want to customize the spinner inside a button, use the slotProps of Button — that's the right composition point.
Default a11y: Spinner ships with role="status" + aria-live="polite" + aria-label="Loading". Screen readers announce loading state on mount.
When the Spinner is inside a larger labeled region (e.g. a panel that already announces "Loading customer details"), the spinner's announce becomes duplicate noise. Pass label="" to suppress role/aria-live:
<section aria-busy="true" aria-label="Loading customer details">
<Spinner label="" /> {/* decorative — no double announce */}
<span>Loading customer details…</span>
</section><Spinner label="Refreshing transactions" />
{/* renders: role="status" aria-live="polite" aria-label="Refreshing transactions" */}| Prop | Type | Default | Description |
|---|---|---|---|
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Diameter — xs:12, sm:16, md:20, lg:24, xl:32 (px). |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | — | Applies text-{color}-600. Omit to inherit currentColor. |
thickness | 'thin' | 'md' | 'thick' | 'md' | SVG stroke-width — thin:1.5, md:2.25, thick:3. |
withTrack | boolean | false | Render faint ghost ring at 20% opacity of currentColor. |
delay | number (ms) | — | Anti-flash. Only render after this many ms have elapsed. |
label | string | 'Loading' | Accessible label. Pass "" (empty string) to switch to decorative mode. |
className | string | — | Merged via tailwind-merge. |
sx | string | — | String-form Tailwind override. |
motion-reduce gate: the spinning animation respects prefers-reduced-motion. When the user has reduced motion enabled, the SVG sits static — the role/aria-live still announces loading, but no rotation.role precedence: when label === '' the component omits role/aria-live entirely (decorative mode). Otherwise both are present.access/visibleWhen). Spinner is pure presentational — no state, no interactivity. If you need to hide a spinner conditionally, wrap it in a parent that has the bridge contract (e.g. <Box visibleWhen={...}>).<Button> and <IconButton>. Visual consistency across the catalog is now guaranteed by a single source.<Skeleton> instead.