Developer Experience (DX)
Next, let's look at how developers use the Spinner. It has no props, events or slots. You render it while something's loading, next to the text that says what, and it takes everything else from where it sits.
import { Spinner } from "@/components/Spinner";
export function DraftStatus({ saving }: { saving: boolean }) {
return (
<p role="status">
{saving && (
<>
<Spinner /> Saving draft
</>
)}
</p>
);
}
Consistent
The Spinner follows the same conventions as every other component. Its styles sit in the components cascade layer, its variable is a token path, and its parts are marked with data-slot.
It also leaves a few props out, on purpose. There's no size prop, since the size of the text around it is almost always the right one, and the components that use it set their own. There's no color prop either, since a spinner has no intent of its own, so it can't be a success or an error. And there's no label, since whatever shows the spinner names the wait. If your design system needs a size prop anyway, give it the same values as every other component, from xs to xl, rather than a scale of its own.
Type-safe
With no props, events or slots, there's nothing of its own to type: <Spinner /> is the whole API. Classes and attributes pass straight through to the <svg>. If you wrap it, type the wrapper's attributes as an <svg>'s, with the type your framework has for them.
Composable
The root element is the <svg> itself, so classes, styles and attributes you pass land directly on it. A component that shows a spinner does three things:
- It marks the Spinner as one of its parts, with
data-slot. - It places it, and sizes it with
--spinner--size, from a selector that's more specific than.spinner. Both sit in thecomponentslayer, so the more specific one wins, whichever stylesheet loads first. - It announces the wait itself, since the Spinner stays silent.
That's all the Button does in its reference implementation, along with hiding its content while the spinner covers it. While it's loading, it renders the Spinner as its last part, with data-slot="spinner", and styles it from there:
@layer components {
/* … */
.button[data-loading] > :not([data-slot="spinner"]) {
opacity: 0;
}
.button > [data-slot="spinner"] {
--spinner--size: var(--button--icon--size);
position: absolute;
inset: 0;
margin: auto;
}
}
Every component that shows a spinner renders this one, rather than drawing its own. That way, you can change your spinner once and see it change everywhere, which is why the Button has no slot for it.
Controllable
The Spinner doesn't keep any state of its own. It turns for as long as it's in the page, so you show it by rendering it and hide it by taking it out, rather than with a prop. Once it's gone, there's nothing left animating. Everything else is within reach from outside:
| What | How you control it |
|---|---|
| Shown | Render it while something's loading, and take it out once it's done |
| Size | --spinner--size, from a class |
| Colour | color, on the Spinner or on the text around it |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/root-element | Must | Component | The root element is the <svg> itself, so classes and attributes like data-slot land on it | Open Components | Unit test |
spinner/stateless | Should | Component | It has no props or state of its own, and it shows for as long as it's rendered | Open Components | Review |
spinner/one-implementation | Should | Usage | Components that show a spinner render the Spinner, rather than drawing one of their own | Open Components | Review |
FAQ
<svg> in your Spinner, and every component that shows a spinner follows, which is why the Button has no slot for its spinner: it's one decision for your whole app, rather than for each button. Keep what makes it a Spinner, though. It's hidden with aria-hidden, drawn in currentColor, 1em by default, pulses with reduced motion and marks its parts with data-slot. If you use an icon, like Lucide's LoaderCircle, leave out any class that spins it, like Tailwind's animate-spin, since the Spinner already does.