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" */}Configure <Spinner> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Spinner: {
defaults: { size: 'md', thickness: 'md', withTrack: true },
},
},
});Configurable axes (SpinnerVariantProps):
| Axis | Type | Notes |
|---|---|---|
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | Diameter tier. |
color | intent role | Applies text-{color}-600. Omit → inherits parent's text color via currentColor. |
thickness | 'thin' | 'md' | 'thick' | SVG stroke-width — thin:1.5, md:2.25, thick:3. |
withTrack | boolean | Render faint ghost ring at 20% opacity of currentColor. |
Non-visual axes (label, delay, visibleWhen) are per-instance — not theme-configurable.
Precedence chain (lowest → highest):
defaultVariants from the internal spinnerVariants recipe (size: 'md', no default color — inherits currentColor).theme.components.Spinner.defaults (application-wide).<Spinner size="xl" color="primary" />) — wins over theme.sx — appended to the root and wins over conflicting classes via tailwind-merge.TypeScript: theme.components.Spinner.defaults autocompletes to the four axes above.
Reactivity: useComponentDefaults('Spinner') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Standard React className — appended to the root via cn(). |
color | SpinnerColor | — | Intent color. When omitted, inherits parent's text color via currentColor — works seamlessly inside Button, Alert, Card, or any colored container without configuration. Pass explicitly for standalone usage on neutral surfaces. |
delay | number | 0 | Wait N milliseconds before rendering — prevents a flash of the spinner on quick operations (Atlassian-style anti-flash). The spinner mounts as null for the first N ms, then swaps to the actual SVG. Pairs naturally with visibleWhen.Common pattern: tsx <Spinner visibleWhen={(e) => e.isSubmitting()} delay={150} /> // Spinner appears only when isSubmitting AND that has been // true for 150ms — quick submits never flash. |
label | string | 'Loading' | Accessible label announced by screen readers via role="status" + aria-live="polite". Rendered as a visually- hidden <span> inside the spinner. |
size | SpinnerSize | 'md' | Diameter tier — xs:12px, sm:16px, md:20px, lg:24px, xl:32px. |
sx | ClassValue | — | Root-element class shortcut (string or clsx-compatible value). Wins over variant classes via tailwind-merge. |
thickness | SpinnerThickness | 'md' | SVG stroke-width — thin:1.5, md:2.25, thick:3. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate — same contract as Alert/Chip/ Button/etc. Spinner is intrinsically conditional (loading IS a state), so this fits naturally even though the component is otherwise display-only. |
withTrack | boolean | false | Renders a faint "ghost ring" behind the spinning arc (20% opacity of currentColor). Improves contrast on busy or dark backgrounds, reads as more "premium" on slow operations.Visually: WITHOUT track you see a rotating partial arc (3/4 circle missing in any frame). WITH track you see the full circle (low-opacity) AND the arc on top. The track stays put while the arc rotates. |
<Spinner> has no slots. It renders a single <span role="status"> wrapping an SVG rotating arc + a visually-hidden <span> label — there is no anatomy to target separately.
The slotProps API pattern only exists on compound components with multiple visually distinct regions. Examples: <Menu>, <DataGrid>, <Autocomplete>.
For <Spinner>, use sx (documented in the Props table above) for any style override. Because color inherits currentColor by default, most consumer needs are met by simply setting a text-* class on the parent (e.g. <Button color="primary"><Spinner /></Button>).
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.visibleWhen is supported (engine predicate → returns null when false), pairing naturally with delay for "show spinner only when the operation is genuinely slow" UX. access is intentionally not supported — loading state is orthogonal to permissions; if you need permission-gated spinners, wrap in <Box access={…}>.<Button> and <IconButton>. Visual consistency across the catalog is now guaranteed by a single source.<Skeleton> instead.