Agentic Experience (AX)
Finally, let's look at how agents read, build and check a tag group. 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 group is a list, so that's what they find, and each tag in it reads the way the Tag says:
- The list. A
listnamed bylabeloraria-labelledby, likelist "Labels", with alistitemper tag, so an agent can find the group by its name and count its tags. - Each tag. Its text, a
linkwhen it has anhref, and abuttonnamed after it when it's removable, likebutton "Remove Bug". - Empty. With no tags, there's no list, only the empty state's text, like
paragraph: No labels. - No grid. There's no
grid,row,gridcell,aria-selectedoraria-pressed, since nobody selects a tag in a group and the arrow keys don't move between them.
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 "Docs":
- /url: /labels/docs
- button "Remove Docs"
With the list's name and each remove button's name, an agent can find a group, remove a tag from it, and check where focus went:
const labels = page.getByRole("list", { name: "Labels" });
await labels.getByRole("button", { name: "Remove Bug" }).click();
await expect(labels.getByRole("button", { name: "Remove Docs" })).toBeFocused();
Described
Here's the whole contract in one block, for agents to read before they build or review a tag group:
component: TagGroup
summary: A named list of Tags that wraps onto rows, moves focus when a tag is removed, announces it and shows an empty state. It fires remove, and never removes a tag itself.
standard: https://opencomponents.dev/docs/components/tag-group
element: div # holds the list, or the empty state. It takes every attribute that isn't a prop, apart from the list's name
role: list # on the <ul>, with a listitem per tag. Each tag is a Tag, with its own link and remove button
props:
tags: { type: "{ value: string, label: string, href?: string, disabled?: boolean, readonly?: boolean }[]" } # a Tag for each, in this order
label: { type: string } # the list's name. Or aria-labelledby, which lands on the list
size: { type: [xs, sm, md, lg, xl], default: md } # passed to every Tag
removable: { type: boolean, default: false }
disabled: { type: boolean, default: false }
readonly: { type: boolean, default: false }
removeLabel: { type: function } # (label) => "Remove Bug"
removedLabel: { type: function } # (label) => "Bug removed"
emptyLabel: { type: string, default: No tags }
fallbackFocus: { type: "HTMLElement | null | () => HTMLElement | null" } # takes focus when no tag with a remove button follows the one removed
slots:
tag: A tag's label, for a router's link. It goes to the Tag's default slot.
leading: An icon before a tag's label. It goes to the Tag's leading slot.
empty: What it shows with no tags, in place of emptyLabel.
events:
remove: The tag whose remove button was pressed. The group keeps it until tags drops it. Once it has gone, focus that was on it moves to the next tag's remove button, then fallbackFocus, then the previous tag's remove button, then the empty state, or the list when tags are left that can't be removed, and removedLabel is announced through a polite live region.
states:
removable: "[data-slot=remove] in each tag"
disabled: "[data-disabled] on every tag"
read-only: "[data-readonly] on every tag, with no [data-slot=remove]"
empty: "> [data-slot=empty], in place of > [data-slot=list]"
keyboard:
Tab: Moves focus to each tag's link, then its remove button, in order, then out of the group. There's no roving focus.
Enter: Follows a link, or fires remove on a remove button.
Space: Fires remove on a remove button.
parts: [list, item, empty] # data-slot. An item per tag inside the list, and the empty state in its place with no tags
tokens:
theme: ["--color--neutral-text", "--color--focus"] # and the Tag's, through its tags
variables: [--tag-group--gap, --tag-group--item--height, --tag-group--row-gap, --tag-group--empty--color, --tag-group--empty--font-size]
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/tag-group#checklist
It's also published on its own at /raw/docs/components/tag-group.yaml, with every rule from the checklist in place of the rules link. Its tags follow the Tag's contract too, so give agents both.
Every rule has a stable ID, like tag-group/focus-after-remove, 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 group of two labels that can be removed:
<TagGroup size="sm" label="Labels" tags={[{ value: "bug", label: "Bug" }, { value: "docs", label: "Docs" }]} removable />
And here's what it renders:
<div class="tag-group" data-size="sm">
<ul data-slot="list" role="list" aria-label="Labels">
<li data-slot="item">
<span class="tag" data-size="sm">
<span data-slot="label">Bug</span>
<button data-slot="remove" type="button" aria-label="Remove Bug">
<svg viewBox="0 0 24 24" aria-hidden="true">…</svg>
</button>
</span>
</li>
<li data-slot="item">
<span class="tag" data-size="sm">
<span data-slot="label">Docs</span>
<button data-slot="remove" type="button" aria-label="Remove Docs">
<svg viewBox="0 0 24 24" aria-hidden="true">…</svg>
</button>
</span>
</li>
</ul>
</div>
With no tags, the list is replaced by the empty state:
<div class="tag-group" data-size="sm">
<p data-slot="empty" tabindex="-1">No tags</p>
</div>
- The size is mirrored as
data-size, on the group and on every tag. - Parts are marked with
data-slot:list, with anitemper tag, orempty. Each tag inside is the Tag's own markup, as its DOM contract says. - Removing a tag removes its
<li>, which the group handles by moving focus.disabledandreadonlytake each tag's remove button away, as the Tag's do, and every other state changes attributes on the tags, never elements, so focus and the screen reader's position survive them. - 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. The list only gets
tabindex="-1"while it holds focus after a removal.
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 TagGroup.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 space between rows or see tags wrap. In the browser, you can remove tags the way people would, check where focus goes each time, and run axe with an ARIA snapshot of what agents will see:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("a search's filters can be removed one by one, and focus never gets lost", async ({ page }) => {
await page.goto("/search?q=tags");
const filters = page.getByRole("list", { name: "Filters" });
await expect(filters).toMatchAriaSnapshot(`
- listitem:
- text: Open
- button "Remove Open"
- listitem:
- text: Bug
- button "Remove Bug"
`);
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
await filters.getByRole("button", { name: "Remove Open" }).press("Enter");
await expect(filters.getByRole("button", { name: "Remove Bug" })).toBeFocused();
await page.getByRole("button", { name: "Remove Bug" }).press("Enter");
await expect(page.getByRole("button", { name: "Reset" })).toBeFocused();
});
Some things still need a human, so try your groups with the keyboard alone, with a screen reader, to hear the list's name and count and each removal announced, on a phone, to check that a thumb never lands on the tag in the next row, and at 200% zoom.
During development
Our reference implementation also warns you in the console as soon as a group's list has no name, from label or aria-labelledby, since screen readers couldn't say what its tags are.
Prompts
You can point your agent straight at this page, which tells it to build on your Tag. To build a tag group, you could use:
Build a Tag Group component for our React design system that meets the Open Components
Tag Group standard: https://opencomponents.dev/raw/docs/components/tag-group.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 Group against the Open Components Tag Group contract:
https://opencomponents.dev/raw/docs/components/tag-group.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 tag groups:
Review how our search page uses its tag groups against the Open Components Tag Group
contract: https://opencomponents.dev/raw/docs/components/tag-group.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 |
|---|---|---|---|---|---|
tag-group/role-and-name | Must | Component | It can be found by its role and name, with getByRole("list", { name: "Labels" }), and each tag by its parts', like getByRole("button", { name: "Remove Bug" }) | WCAG 4.1.2 (A) | Unit test |
tag-group/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs. The list renders only with tags and the empty state only without, disabled and readonly take each tag's remove button away, and every other state changes attributes on the tags, never elements | Open Components | Unit test |
tag-group/data-attributes | Should | Component | size is mirrored as data-size, and parts carry data-slot: list, item and empty | Open Components | Unit test |
Developer Experience
How developers use the Tag Group, with one typed API that takes a list of tags, fires one event and keeps no tags of its own.
Overview
How to build a textarea that people can label, write in at length and fix, that grows or resizes without cutting anything off, and that agents can check.