Tag Group

Agentic Experience (AX)

How agents read, build and check a tag group, 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 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 list named by label or aria-labelledby, like list "Labels", with a listitem per tag, so an agent can find the group by its name and count its tags.
  • Each tag. Its text, a link when it has an href, and a button named after it when it's removable, like button "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-selected or aria-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 an item per tag, or empty. 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. disabled and readonly take 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

RuleLevelScopeRequirementBasisCheck
tag-group/role-and-nameMustComponentIt 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-domMustComponentThe 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 elementsOpen ComponentsUnit test
tag-group/data-attributesShouldComponentsize is mirrored as data-size, and parts carry data-slot: list, item and emptyOpen ComponentsUnit test
Copyright © 2026