Spinner

Agentic Experience (AX)

How agents read, build and check a spinner, from the text around it to its contract, its DOM and its tests.

Finally, let's look at how agents read, build and check a spinner. They rely on the same accessibility tree as assistive technologies, which leaves the Spinner out, and on a contract they can check the code against.

Semantic

The Spinner isn't in the accessibility tree, so agents that read the page through it, with Playwright or a browser MCP server, don't see it at all. What they see is the text that says what's loading, which is one more reason to always show some. This is how they see the example under Say what's loading, while it checks:

- button "Check for updates" [disabled]
- status: Checking for updates

In a test, wait for that text rather than for the spinner:

await expect(page.getByRole("status")).toHaveText("You're up to date");

A spinner without any text around it leaves agents, like screen reader users, with nothing to wait for.

Described

Here's the whole contract in one block, for agents to read before they build or review a spinner:

component: Spinner
summary: Shows that something is busy, for a wait of unknown length. Visual only.
standard: https://opencomponents.dev/docs/components/spinner
element: svg # aria-hidden="true", with no role or name: whatever shows it says what's loading
props: {} # none: it takes its size and colour from the text around it
parts: [track, indicator] # data-slot, in this order
tokens:
  theme: [] # none: it's drawn in currentColor
  variables: [--spinner--size] # 1em by default
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/spinner#checklist

It's also published on its own at /raw/docs/components/spinner.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like spinner/say-what-loads, so you and your agents can cite it in reviews and commits.

Deterministic

The Spinner always renders the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes:

<svg class="spinner" viewBox="0 0 16 16" aria-hidden="true">
  <circle data-slot="track" cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="2" opacity="0.25" />
  <path data-slot="indicator" d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
  • Its parts are marked with data-slot and always come in the same order: track, then indicator.
  • There's nothing random or generated. It's drawn with plain strokes rather than a gradient, which would need an id, so the markup is the same on the server, on the client and in every snapshot, however many spinners there are on the page.
  • Attributes you pass are added to the <svg>, like the Button's data-slot="spinner".

Verifiable

Every rule in the checklist can be checked, and about a third of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Spinner.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

In the browser

Most of the Spinner's rules are about the page around it, so they're best checked in the browser. You can hold a request back to look at the page while it waits, use an ARIA snapshot to check what agents and screen readers find, emulate reduced motion, and run axe over the lot:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("checking for updates says what it's doing, and how it went", async ({ page }) => {
  await page.emulateMedia({ reducedMotion: "reduce" });

  // Hold the check back until the test lets it go.
  let finish!: () => void;
  const finished = new Promise<void>((resolve) => (finish = resolve));
  await page.route("**/api/updates", async (route) => {
    await finished;
    await route.fulfill({ json: { available: false } });
  });

  await page.goto("/settings");
  await page.getByRole("button", { name: "Check for updates" }).click();

  // Agents and screen readers find the text, never the spinner, which pulses instead of turning.
  await expect(page.getByRole("status")).toMatchAriaSnapshot(`- status: Checking for updates`);
  await expect(page.locator(".spinner")).toHaveCSS("animation-name", /pulse/);

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);

  // Once it's done, the spinner is gone and the outcome is in its place.
  finish();
  await expect(page.getByRole("status")).toHaveText("You're up to date");
  await expect(page.locator(".spinner")).toHaveCount(0);
});

Some things still need a human. Try the page with a screen reader, and check that the spinner is never read out, while the page says what's loading and how it went. Then turn on forced colours, which you can emulate in Chrome DevTools under Rendering, and check that the spinner stays visible.

Prompts

You can point your agent straight at this page. To build a spinner, you could use:

Build a Spinner component for our React design system that meets the Open Components
Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Spinner against the Open Components Spinner contract:
https://opencomponents.dev/raw/docs/components/spinner.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen shows its loading states:

Review how our search page uses its spinners against the Open Components
Spinner contract: https://opencomponents.dev/raw/docs/components/spinner.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

AX Checklist

RuleLevelScopeRequirementBasisCheck
spinner/deterministic-domMustComponentIt always renders the same markup, with nothing generated, like an idOpen ComponentsUnit test
spinner/data-attributesShouldComponentIts parts are marked with data-slot, in a fixed orderOpen ComponentsUnit test
Copyright © 2026