Agentic Experience (AX)
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-slotand always come in the same order:track, thenindicator. - 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'sdata-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
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/deterministic-dom | Must | Component | It always renders the same markup, with nothing generated, like an id | Open Components | Unit test |
spinner/data-attributes | Should | Component | Its parts are marked with data-slot, in a fixed order | Open Components | Unit test |