Spinner

Developer Experience (DX)

How developers use the Spinner, with no props, events or slots, and one implementation that every component shares.

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 the components layer, 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:

WhatHow you control it
ShownRender it while something's loading, and take it out once it's done
Size--spinner--size, from a class
Colourcolor, on the Spinner or on the text around it

DX Checklist

RuleLevelScopeRequirementBasisCheck
spinner/root-elementMustComponentThe root element is the <svg> itself, so classes and attributes like data-slot land on itOpen ComponentsUnit test
spinner/statelessShouldComponentIt has no props or state of its own, and it shows for as long as it's renderedOpen ComponentsReview
spinner/one-implementationShouldUsageComponents that show a spinner render the Spinner, rather than drawing one of their ownOpen ComponentsReview

FAQ

Copyright © 2026