Button

Agentic Experience (AX)

How agents read, build and check a button, from its accessibility tree to its contract, its DOM and its tests.

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

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how they see the examples on this page:

- button "Cancel"
- button "Schedule…"
- button "Publish post"
- button "Save" [disabled]
- button "Mute" [pressed]
- button "Options" [expanded]
- button "Delete"
- link "Read the introduction":
  - /url: /docs

A <div> with a click handler has no role, so neither agents nor screen readers see it as a button. A real button has a role, a name and states, so an agent can tell what pressing it will do, and find it by its name:

await page.getByRole("button", { name: "Save changes" }).click();

Described

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

component: Button
summary: Runs an action in place. Renders a link when given an href.
standard: https://opencomponents.dev/docs/components/button
element: button # a, with href
role: button # link, with href
props:
  variant: { type: [solid, outline, soft, subtle, ghost, link], default: solid }
  color: { type: [primary, secondary, neutral, success, info, warning, error], default: neutral }
  size: { type: [xs, sm, md, lg, xl], default: md }
  label: { type: string }
  iconOnly: { type: boolean, default: false }
  type: { type: [button, submit, reset], default: button }
  href: { type: string }
  disabled: { type: boolean, default: false }
  focusableWhenDisabled: { type: boolean, default: false }
  loading: { type: boolean, default: false }
  loadingLabel: { type: string, default: Loading }
slots:
  default: The label.
  leading: An icon before the label, and the icon of an icon-only button.
  trailing: An icon after the label.
events:
  click: MouseEvent. Never fires while disabled or loading.
states:
  hover: ":hover, in @media (hover: hover)"
  active: ":active"
  focus: ":focus-visible"
  disabled: "[disabled], or [aria-disabled=true] when focusableWhenDisabled"
  loading: "[aria-disabled=true][data-loading]"
  pressed: "[aria-pressed=true]"
  expanded: "[aria-expanded=true]"
keyboard:
  Enter: Activates it. Links too.
  Space: Activates it, on release. Buttons only.
parts: [leading, label, trailing, spinner] # data-slot, in this order. The spinner is a Spinner: https://opencomponents.dev/docs/components/spinner
tokens:
  theme: ["--color--{color}", "--color--{color}-contrast", "--color--{color}-text", "--color--focus"]
  variables: [--button--height, --button--padding, --button--gap, --button--font-size, --button--icon--size, --button--radius]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/button#checklist

It's also published on its own at /raw/docs/components/button.yaml, with every rule from the checklist in place of the rules link. It's about a tenth 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 button/keep-focus, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a primary Button that's loading:

<Button color="primary" loading>Save</Button>

And here's what it renders:

<button
  class="button"
  type="button"
  aria-disabled="true"
  data-variant="solid"
  data-color="primary"
  data-size="md"
  data-loading=""
>
  <span data-slot="label">Save</span>
  <svg class="spinner" viewBox="0 0 16 16" aria-hidden="true" data-slot="spinner">…</svg>
</button>
  • Props are mirrored as data-* attributes. data-variant, data-color and data-size are always there, while data-icon-only and data-loading only appear when they're set.
  • Parts are marked with data-slot and always come in the same order: leading, label, trailing and spinner, which is a Spinner.
  • States change attributes, never elements. The focused element is never replaced, so both focus and the screen reader's position survive every state change.
  • There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot.
  • Icon-only is always a prop, and never guessed from the content.

Verifiable

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

Unit tests

Every rule that the checklist checks with a unit test has one in Button.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

In the browser, you can run axe to check names, contrast, target size and nesting, and use an ARIA snapshot to check what agents will see:

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

test("the publish dialog's buttons", async ({ page }) => {
  await page.goto("/posts/new");

  await expect(page.getByRole("dialog")).toMatchAriaSnapshot(`
    - button "Cancel"
    - button "Schedule…"
    - button "Publish post"
  `);

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

Some things still need a human, so try the button with the keyboard alone, with a screen reader and at 200% zoom, and with forced colours, more contrast and reduced motion turned on. You can emulate all three in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a button renders without an accessible name, which is the most common button bug out there.

Prompts

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

Build a Button component for our React design system that meets the Open Components
Button standard: https://opencomponents.dev/raw/docs/components/button.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 Button against the Open Components Button contract:
https://opencomponents.dev/raw/docs/components/button.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 uses its buttons:

Review how our checkout page uses its buttons against the Open Components Button
contract: https://opencomponents.dev/raw/docs/components/button.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
button/role-and-nameMustComponentIt can be found by its role and name alone, with getByRole("button", { name })WCAG 4.1.2 (A)Unit test
button/deterministic-domMustComponentThe same props render the same markup, and states change attributes, never elementsOpen ComponentsUnit test
button/data-attributesShouldComponentProps are mirrored as data-* attributes, and parts are marked with data-slotOpen ComponentsUnit test
button/dev-warningsShouldComponentIt warns during development when a button has no accessible nameOpen ComponentsUnit test
Copyright © 2026