Skip to content

Spinner

Work is happening and there is no saying how much is left. That is the whole of what a spinner claims, and it is the reason to choose one: when the amount of work is known, a progress bar says something a spinner cannot, and a reader watching a spinner for eight seconds learns nothing from it but that the application has not given up.

It is a status rather than a progress bar, and it carries busy. It has no value, because it has none to have.

tsx
/**
 * Three spinners, and everything there is to a spinner.
 *
 * It says work is happening and cannot say how much, so it is a
 * `status` carrying `busy` rather than a progress bar, and it takes no
 * value because it has none to take. What it does take is a size, a
 * label and a colour: the blades are boxes painted with a palette
 * token, so unlike an icon this one resolves its colour at paint and
 * follows a theme without redrawing anything.
 *
 * The first has no label, and is still announced: a spinner with
 * nothing to say defaults to "Loading", where a picture with no `alt`
 * would have dropped out of the semantics tree instead. The middle one
 * is the usual shape, a spinner beside the sentence it belongs to. The
 * last is what a spinner looks like when it is the only thing on a
 * screen.
 *
 * All three keep turning when the reader has asked for reduced motion.
 * A spinner that stands still is not a calmer spinner; it is one that
 * says the work has stopped.
 */
export function Waiting(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  return (
    <column gap={18} padding={20} width={percent(100)} height={percent(100)}>
      <row gap={12} y="center">
        <Spinner />
        <text text="No label: the accessible name is Loading." fontSize={12} color="textMuted" />
      </row>
      <row gap={12} y="center">
        <Spinner size={20} label="Working" />
        <text text="Uploading three files…" fontSize={13} />
      </row>
      <row gap={12} y="center">
        <Spinner size={36} label="Opening the workspace" color="textMuted" />
        <text text="A larger one, in a colour of its own." fontSize={12} color="textMuted" />
      </row>
    </column>
  );
}

Props

PropTypeDefaultWhat it does
sizenumber20The side of the square it occupies, in logical pixels.
labelstring'Loading'What a screen reader reads while it turns.
colorUiColorValuecontrolAccentA palette name or a colour outright, for the blades.

size fixes the box and flexShrink is zero, so a spinner in a tight row keeps its size. The layout props on the library page apply.

color names a palette entry, and unlike an icon this one resolves at paint: the blades are boxes rather than a raster, so a theme change recolours them with nothing redrawn.

rootModifiers reaches the box the blades sit in, so a sharedElement or a motion can be put on a spinner without wrapping it in a box of its own.

What a turn costs

Eight blades of fixed, decreasing opacity sit in a container, and the container's rotation is the only thing that changes. A turn is eight positions 110 ms apart, so the whole animation is one property written about nine times a second, rather than eight properties written on every frame. It is a repeating tween with a stepped easing and a step the length of one position, so the runtime wakes for it about nine times a second, on the same phase as every other animation in the application, and stops the moment the component leaves.

The turn is about the middle of the square. That is worth stating because it was once wrong: a transform's x and y are its pivot, offset from the node's top left, not an additional translation, so a spinner given no pivot turns about its corner and swings across the row it sits in. Half the side each is what makes it turn on the spot, and the spec beside the example holds the pivot in place.

Reduced motion

It keeps turning. That is deliberate, and it is the same call a video makes: the rule is to stop a movement only where standing still would not say something false, and a spinner that has stopped is one that says the work has stopped. WCAG's rule about reduced motion is about movement triggered by interaction, and a busy indicator is not that.

The spec beside the example mounts the whole thing with reduced motion on and measures that the spinner still turns.

An application that wants no movement at all shows something else instead: a line of text, or a progress bar with a value, both of which say more than a spinner does anyway.

Keyboard

None. A spinner is not a tab stop and binds no keys. It is also not somewhere focus should be sent when work starts: announce the state through the control that started it.

Semantics

WhatValue
Rolestatus, on the square that turns
Namelabel, which is Loading when none was given
Statesbusy, always. A spinner that is not busy should not be drawn
ValueNone. It cannot say how far along the work is

A spinner with no label is still announced, which is the opposite of what an image or an icon does with a missing name. The reason is that a spinner is never decorative: it exists to say something is happening, so it defaults to saying it rather than dropping out of the tree. Replace the default wherever you can name the work, since "Loading" said three times on one screen is three anonymous waits.

Next

ProgressBar is the one to reach for when the work can say how far along it is, and it is the same component in its indeterminate form when it cannot.