Card

Agentic Experience (AX)

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

Finally, let's look at how agents read, build and check a card. 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 cards under Layout:

- list:
  - listitem:
    - heading "Analytical Engine" [level=3]:
      - link "Analytical Engine":
        - /url: /projects/analytical-engine
    - paragraph: A general-purpose mechanical computer, first described in 1837.
    - button "Save"
  - listitem:
    - heading "Difference Engine" [level=3]:
      - link "Difference Engine":
        - /url: /projects/difference-engine
    - paragraph: A machine for working out tables of numbers.
    - button "Save"

The images are left out, since their alt is empty. A card that's a <div> with a click handler shows up as a heading and some text, with nothing to say that pressing it does anything, so an agent can't follow it any more than a keyboard user can. With a real link, named by the heading, an agent can find the card and follow it, or find one of its actions:

await page.getByRole("link", { name: "Analytical Engine" }).click();

const card = page.getByRole("listitem").filter({ has: page.getByRole("heading", { name: "Analytical Engine" }) });
await card.getByRole("button", { name: "Save" }).click();

Described

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

component: Card
summary: Groups what people need to know about one thing, like a project or a product, under a heading. With an href, its heading is a link stretched over the whole card.
standard: https://opencomponents.dev/docs/components/card
element: article # or section, div or li, with as
role: article # listitem as an li. A section is a region only once it has a name
props:
  as: { type: [article, section, div, li], default: article }
  heading: { type: string } # the heading slot takes precedence
  headingLevel: { type: [2, 3, 4, 5, 6], default: 3 }
  href: { type: string } # links the card through its heading
slots:
  default: The body, like a sentence or two and some details.
  heading: The heading, when it's more than plain text, or a router's link, which is stretched too.
  eyebrow: A short line shown above the heading, like a category, which comes after it in the DOM.
  media: An image, shown at the top, which comes after the heading in the DOM.
  actions: Buttons that act on the card, like Save, which sit above the stretched link.
  footer: A last line of details about the card, like who made it or when.
states:
  hover: ":has(> [data-slot=header] > [data-slot=heading] a[href]:hover), in @media (hover: hover)"
  focus: "[data-slot=heading] a[href]:focus-visible, drawn around the card by its ::after"
keyboard:
  Tab: Moves focus to the card's link, then to its actions.
  Enter: Follows the link.
parts: [header, heading, link, eyebrow, media, body, actions, footer] # data-slot, in this order. The link is in the heading
tokens:
  theme: ["--color--neutral-text", "--color--focus"]
  variables: [--card--padding, --card--gap, --card--radius, --card--border-color, --card--background, --card--media--aspect-ratio]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/card#checklist

It's also published on its own at /raw/docs/components/card.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 card/stretched-link, 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 card in a list, that links to a project:

<Card as="li" heading="Analytical Engine" href="/projects/analytical-engine">
  <p>A general-purpose mechanical computer.</p>
</Card>

And here's what it renders:

<li class="card">
  <div data-slot="header">
    <h3 data-slot="heading">
      <a data-slot="link" href="/projects/analytical-engine">Analytical Engine</a>
    </h3>
  </div>
  <div data-slot="body">
    <p>A general-purpose mechanical computer.</p>
  </div>
</li>
  • The element is always the one as names, and the heading's level always the one headingLevel names, so neither is guessed from where the card is on the page.
  • Parts are marked with data-slot and always come in the same order: the header, which holds the heading, its link and the eyebrow, then the media, the body, the actions and the footer. Only the header and the heading are always there, and the others render when they're given.
  • Hover and focus are styles, never changes to the markup, so focus and the screen reader's position survive both.
  • 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 a third of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Card.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 press the card's text and check that the stretched link catches it. In the browser, you can press the card the way people would, check that its actions don't follow the link, and run axe to check contrast, alt text and the list:

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

test("a project card saves from its actions, and follows its link from anywhere else", async ({ page }) => {
  await page.goto("/projects");

  const card = page.getByRole("listitem").filter({ has: page.getByRole("heading", { name: "Analytical Engine" }) });
  await card.getByRole("button", { name: "Save" }).click();
  await expect(page).toHaveURL(/\/projects$/);

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

  // The middle of the card is its body text, which the stretched link covers.
  await card.click();
  await expect(page).toHaveURL(/\/projects\/analytical-engine$/);
});

Some things still need a human, so try your cards with the keyboard alone, with a screen reader, where you can list the page's headings 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.

Prompts

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

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

Review how our projects page uses its cards against the Open Components Card
contract: https://opencomponents.dev/raw/docs/components/card.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
card/role-and-nameMustComponentIt can be found by its heading, with getByRole("heading", { name }), and a card that's a link by getByRole("link", { name })WCAG 4.1.2 (A)Unit test
card/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and parts only render when they're givenOpen ComponentsUnit test
card/data-attributesShouldComponentParts are marked with data-slotOpen ComponentsUnit test
Copyright © 2026