Tag

Agentic Experience (AX)

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

Finally, let's look at how agents read, build and check a tag. 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. A tag has no role of its own, so what they see depends on what people can do with it:

  • On its own. A plain tag is text, with no role, which agents and screen readers read where it sits, like the words around it.
  • As a link. A link tag is a link, named by its text, like link "Bug", whether the Tag rendered it from href or you put your router's link in its default slot.
  • Removable. A removable tag adds a button after its text, named after it, like button "Remove Bug". A disabled or read-only tag has no button.
  • In a list. The Tag adds no list semantics of its own. Put tags in a <ul>, with a name like "Labels", and screen readers say "list, Labels, 3 items" before they read each tag's text and button. The Tag Group does that for you.

This is how they see the labels in the example under Developer Experience, where every tag is a link and removable:

- list "Labels":
  - listitem:
    - link "Bug":
      - /url: /labels/bug
    - button "Remove Bug"
  - listitem:
    - link "Feature":
      - /url: /labels/feature
    - button "Remove Feature"

A remove button that's an icon with no name shows up as button, with nothing to say which tag it removes, so an agent can't pick the right one any more than a screen reader user can. With a name built from the label, an agent can find a tag and remove it, or follow it:

await page.getByRole("button", { name: "Remove Bug" }).click();
await page.getByRole("link", { name: "Feature" }).click();

Described

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

component: Tag
summary: A short label for what something is about, like a topic or a picked value, that people can remove or follow as a link. It fires remove, and never removes itself.
standard: https://opencomponents.dev/docs/components/tag
element: span # no role of its own. Its link and its remove button carry theirs
props:
  label: { type: string } # needed when removable, since the remove button's name is built from it
  size: { type: [xs, sm, md, lg, xl], default: md }
  href: { type: string } # makes the label a link
  removable: { type: boolean, default: false }
  removeLabel: { type: function } # (label) => "Remove Bug"
  disabled: { type: boolean, default: false }
  readonly: { type: boolean, default: false }
slots:
  default: The label, when it's more than plain text, or a router's link, which the tag styles like its own.
  leading: An icon before the label, hidden from assistive technologies.
  remove: The remove button's icon. The button and its name stay.
events:
  remove: MouseEvent. Fires when the remove button is pressed. Never fires while disabled or readonly, and the tag doesn't remove itself.
states:
  link: ":has(> [data-slot=label] a)"
  removable: ":has(> [data-slot=remove])"
  hover: "[data-slot=label] a[href]:hover, [data-slot=remove]:hover, in @media (hover: hover)"
  focus: "[data-slot=label] a:focus-visible, drawn around the tag by its ::after, and [data-slot=remove]:focus-visible"
  disabled: "[data-disabled]"
  read-only: "[data-readonly]"
keyboard:
  Tab: Moves focus to the tag's link, then to its remove button. A plain or disabled tag isn't a stop.
  Enter: Follows the link, or fires remove on the remove button.
  Space: Fires remove on the remove button.
parts: [leading, label, remove] # data-slot, in this order. The label holds the link, when there's one
tokens:
  theme: ["--color--neutral-text", "--color--focus"]
  variables: [--tag--height, --tag--padding, --tag--gap, --tag--radius, --tag--font-size, --tag--background, --tag--color, --tag--border-color, --tag--icon--size, --tag--link--border-color, --tag--hover--background]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/tag#checklist

It's also published on its own at /raw/docs/components/tag.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 tag/remove-name, 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 small tag that's a link, with an icon, that can be removed:

<Tag size="sm" label="Bug" href="/labels/bug" removable>
  <BugIcon slot="leading" />
</Tag>

And here's what it renders:

<span class="tag" data-size="sm">
  <span data-slot="leading" aria-hidden="true"><svg class="lucide-bug">…</svg></span>
  <span data-slot="label"><a href="/labels/bug">Bug</a></span>
  <button data-slot="remove" type="button" aria-label="Remove Bug">
    <svg viewBox="0 0 24 24" aria-hidden="true">…</svg>
  </button>
</span>
  • Props are mirrored as data-* attributes. data-size is always there, while data-disabled and data-readonly only appear when they're set.
  • Parts are marked with data-slot and always come in the same order: leading, label and remove. Only the label is always there. The leading icon renders when it's given, and the remove button only when the tag is removable and neither disabled nor read-only.
  • The label holds an <a> only with href, or when you put a link in it. Every other state changes attributes, never elements, so focus and the screen reader's position survive hover and focus.
  • 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.

Verifiable

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

Unit tests

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

Unit tests run without a layout, so they can't measure the remove button's hit area or see the ellipsis on a long label. In the browser, you can remove a tag the way people would, check where focus goes, and run axe to check names, contrast and nesting, with an ARIA snapshot of what agents will see:

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

test("an issue's labels can be removed, and focus moves to the next one", async ({ page }) => {
  await page.goto("/issues/42");

  const labels = page.getByRole("list", { name: "Labels" });
  await expect(labels).toMatchAriaSnapshot(`
    - listitem:
      - link "Bug"
      - button "Remove Bug"
    - listitem:
      - link "Feature"
      - button "Remove Feature"
  `);

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

  await labels.getByRole("button", { name: "Remove Bug" }).click();
  await expect(labels.getByRole("button", { name: "Remove Feature" })).toBeFocused();
});

Some things still need a human, so try your tags with the keyboard alone, with a screen reader, where you can list the page's buttons and links, on a phone, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a tag is removable without a label, since its remove button would have no text to say what it removes.

Prompts

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

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

Review how our issue page uses its tags against the Open Components Tag
contract: https://opencomponents.dev/raw/docs/components/tag.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
tag/role-and-nameMustComponentIt can be found by its parts' roles and names, with getByRole("button", { name: "Remove Bug" }) and, as a link, getByRole("link", { name: "Bug" })WCAG 4.1.2 (A)Unit test
tag/deterministic-domMustComponentThe same props render the same markup, with no generated IDs. The remove button renders only when removable is set and the tag is neither disabled nor read-only, and the link only with href or a slotted link, while every other state changes attributes, never elementsOpen ComponentsUnit test
tag/data-attributesShouldComponentsize, disabled and readonly are mirrored as data-*, and parts carry data-slot: leading, label and removeOpen ComponentsUnit test
Copyright © 2026