Spinner
Signals an indeterminate wait where the resulting content has no known shape.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Spinner is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Spinner } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Signals an indeterminate wait where the resulting content has no known shape.
Rules
- Use a spinner for actions: saving, submitting, connecting. Use Skeleton for content that is about to fill a known layout.
- Below roughly 300ms, show nothing. A flash of spinner reads as a glitch.
- The
labelis announced viarole="status". Make it specific: "Saving changes" beats "Loading". - Never centre a spinner in an empty page for more than a few seconds without a cancel path or a progress estimate.
Tradeoffs
Spinners give no sense of duration, so long waits feel longer under one. If you know the progress, use Progress instead.
Props
The typed contract, from Spinner.d.ts.
import * as React from "react";
/** Indeterminate loading indicator. */
export interface SpinnerProps extends React.HTMLAttributes<HTMLSpanElement> {
/** @default "md" */
size?: "sm" | "md" | "lg";
/** Announced to screen readers. @default "Loading" */
label?: string;
/** Render inline with text rather than as a block. @default false */
inline?: boolean;
}
export declare function Spinner(props: SpinnerProps): React.JSX.Element;
Tokens it reads
Every custom property Spinner.jsx resolves, read from the source: 4 semantic, 1 undeclared. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- none Referenced by the source but declared by no tier, so it resolves to nothing.
| Token | Tier | Declared as |
|---|---|---|
--dt-border-default | semantic | var(--dt-color-neutral-200) |
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-space-inline-xs | semantic | var(--dt-dim-2) |
--dt-surface-action | semantic | var(--dt-color-neutral-900) |
--dt-motion-duration-slow | none | not declared |
Source
React only, no dependencies, every value a token.
import React from "react";
const SIZES = { sm: 14, md: 18, lg: 24 };
export function Spinner({ size = "md", label = "Loading", inline = false, style, ...rest }) {
const px = SIZES[size] || SIZES.md;
return (
<span role="status" aria-live="polite" style={{ display: inline ? "inline-flex" : "flex", alignItems: "center", gap: "var(--dt-space-inline-xs)", ...style }} {...rest}>
<span aria-hidden="true" style={{
width: px, height: px, flex: "none", borderRadius: "var(--dt-radius-pill)",
border: "2px solid var(--dt-border-default)", borderTopColor: "var(--dt-surface-action)",
animation: "dt-spin var(--dt-motion-duration-slow, 700ms) linear infinite",
}} />
<span style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0 }}>{label}</span>
</span>
);
}
In context
Spinner also appears on the feedback reference cards, alongside the rest of its family.