Analytical Engine
Project
A general-purpose mechanical computer, first described in 1837.
Cards group what people need to know about one thing, like a project, a product or a person, so they can scan a page of them and pick one. They look like a box with a picture in it, which is why they're so often built with only a mouse in mind. Think of a card wrapped in a link, which a screen reader reads out from its first word to its last as the link's name, a <div> with a click handler, which keyboard users can't reach at all, or a Save button nested inside that link, which HTML doesn't allow.
This page walks you through building one the right way. We'll look at:
Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to tab through them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Card with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.
A card is a container rather than a control, so most of what it does comes from the elements in it: a heading, a link when the whole card takes you somewhere, and Buttons for anything else people can do with it.
Project
A general-purpose mechanical computer, first described in 1837.
<Card
heading="Analytical Engine"
href="/projects/analytical-engine"
eyebrow="Project"
media={<img src="/projects/analytical-engine.jpg" alt="" />}
actions={<Button size="sm" variant="outline">Save</Button>}
footer="By Charles Babbage"
>
<p>A general-purpose mechanical computer, first described in 1837.</p>
</Card>| Aspect | Card |
|---|---|
| Element | <article>, or a <section>, <div> or <li> with as |
| Role | article, or listitem as an <li> |
| Name | Its heading, which also names its link when the card is one |
| Keyboard | Tab to its link, then to its actions, and Enter to follow the link |
| States | Hover and focus, when it's a link |
| Parts | Media, a header with the heading and an eyebrow, body, actions and footer, of which only the header and its heading are always there |
| Pattern | None in the APG, since an <article> and a link cover it |
| WCAG 2.2 | 1.1.1, 1.3.1, 1.3.2, 1.4.3, 1.4.11, 2.1.1, 2.4.4, 2.4.6, 2.4.7 and 4.1.2, among others |
Let's start with how a card looks: its parts, what goes in each of them, how cards sit together on a page and how a card shows its states.
<article> by default, which draws the border and holds the parts.Every card has a container and a header with a heading in it. The parts have no borders of their own, so a card reads as one block, from top to bottom. If a long card, like a settings panel, needs a line between two of its parts, that's a separator's job (see Content).
On screen, the media comes first, then the eyebrow and the heading, but in the DOM the heading comes first of all, with the eyebrow and the media after it (see Put the heading first). The parts always render in the same order, which you can see in the DOM contract.
A card can be as small as a heading and a sentence, or hold every part. You can put any image in media, a short line like a category in eyebrow, Buttons in actions and details like the author in footer, while the body is whatever you put inside the card.
Babbage's machine for working out tables of numbers, by the method of differences.
Project
Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.
<Card heading="Difference Engine">
<p>Babbage's machine for working out tables of numbers, by the method of differences.</p>
</Card>
<Card
heading="Analytical Engine"
eyebrow="Project"
media={<img src="/projects/analytical-engine.jpg" alt="" />}
actions={<Button size="sm" variant="outline">Save</Button>}
footer="By Charles Babbage"
>
<p>Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.</p>
</Card>
alt, so screen readers skip it rather than read the name twice. You'll find more under Describe the media.outline or ghost Button rather than a solid one, so a page of cards doesn't fill up with primary actions.<hr aria-hidden="true">, rather than a plain <hr>, which screen readers announce. To run edge to edge, it takes the card's padding back with negative margins, like margin-inline: calc(-1 * var(--card--padding)).A set of cards is a list, so put them in a <ul>, with each card as an <li> (as="li"). Screen readers then say how many cards there are, and let people skip past the whole list. A grid that fits as many cards as there's room for, each at least a set width, works on every screen without a breakpoint:
A general-purpose mechanical computer, first described in 1837.
A machine for working out tables of numbers.
Ada Lovelace's program for working out Bernoulli numbers on the Analytical Engine.
<ul className="projects">
{projects.map((project) => (
<Card
key={project.id}
as="li"
heading={project.name}
href={project.url}
media={<img src={project.image} alt="" />}
actions={<Button size="sm" variant="outline">Save</Button>}
>
<p>{project.summary}</p>
</Card>
))}
</ul>
.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
min(16rem, 100%) lets a card get narrower than 16rem on a narrow screen rather than overflow it, so the page never scrolls sideways at 320px wide (WCAG 1.4.10).order, row-reverse or grid-auto-flow: dense. Tab follows the DOM, so a card that's moved on screen makes focus jump around (WCAG 1.3.2, 2.4.3).list-style: none, so VoiceOver doesn't say how many items there are. If that matters to you, add role="list" to the <ul>.A card on its own has no states, since there's nothing to do with it. A card that's a link has two, hover and focus, and both come from its link, so they only ever show when pressing the card would follow it.
A machine for working out tables of numbers.
A general-purpose mechanical computer.
<Card heading="Difference Engine">
<p>A machine for working out tables of numbers.</p>
</Card>
<Card heading="Analytical Engine" href="/projects/analytical-engine">
<p>A general-purpose mechanical computer.</p>
</Card>
| State | Selector | Recommended look |
|---|---|---|
| Hover | :has(a[href]:hover) on the root, inside @media (hover: hover) | The border at 45% of the text colour, up from 20%, and the heading underlined |
| Focus | a[href]:focus-visible::after, the link's pseudo-element | A 2px outline, 2px outside the card, in --color--focus |
Both states read the link, rather than the card. That way, the card only looks hovered while the pointer is over the part of it that follows the link, and not over one of its actions, which shows its own hover instead. None of the states change the card's size or position, and you'll find how they behave under Every state.
The card reads two theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral-text | The text, and mixed into the border, the eyebrow, the footer and the media's placeholder |
--color--focus | The focus ring, which is the same for every component |
Everything else is mixed from the text colour: 70% for the eyebrow and the footer, which need 4.5:1, and 20% for the border at rest and 45% on hover. With our reference tokens, from the Tailwind CSS palette, the eyebrow and the footer come to 5.6:1 on white and 7.4:1 on zinc-900, the same as the Input's placeholder. The border is lighter than the Input's, since a card's heading says what it is, and WCAG only asks for 3:1 on a boundary people need to see to find the component (WCAG 1.4.11). You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a card, you can set its own variables, such as --card--padding, --card--background or --card--media--aspect-ratio, from a class. The Card's styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./ProjectCard.module.css";
<Card className={styles.project} heading="Analytical Engine" media={<img src="/projects/analytical-engine.jpg" alt="" />}>
<p>A general-purpose mechanical computer.</p>
</Card>
.project {
--card--background: var(--color--neutral-surface);
--card--media--aspect-ratio: 4 / 3;
}
--color--neutral-surface stands in for a surface colour from your own theme, since the theme tokens don't have one. The Card is transparent by default, so it sits on whatever's behind it. If you give it a background, check the text's contrast against that background rather than the page's.
Now let's look at how a card behaves for every person, input and setting. A good card is accessible, predictable, designed in every state and adaptive.
The Card renders an <article> by default, which HTML defines as a self-contained piece of a page, one you could take out and still make sense of, like a project, a product or a post. That's what most cards are. The as prop picks another element when a card isn't one of those:
| Element | as | When |
|---|---|---|
<article> | The default | The card stands on its own, like a project or a product |
<li> | "li" | The card is one of a list, inside a <ul> or an <ol> |
<section> | "section" | The card is a part of the page, like a settings panel, rather than a thing of its own |
<div> | "div" | The card is only a box, and its content says everything |
We recommend never making the card itself a link or a button. A card holds a heading, text and sometimes actions of its own, and a link or a button can't hold any of them well: its whole content becomes its name, and it can't have another link or button inside it. When the whole card should take people somewhere, its heading holds the link, as under Make the heading the link.
The heading is how people find a card. Most screen reader users look for what they need on a page by moving from heading to heading, according to every WebAIM Screen Reader User Survey, and the heading is what they hear first when they land on a card. It's also what sighted people read first, and what tests and agents look for.
<h2> to <h6>, so it's a heading for assistive technologies too, rather than bold text.headingLevel. Cards in a section titled with an <h2> get an <h3>, which is the default, while cards right under the page's <h1> get an <h2>. A level that skips one, like an <h4> under an <h2>, makes people wonder what they missed.When the whole card takes people somewhere, like a project's page, put the link in the heading, around the heading's text, and stretch it over the card. href does that for you. The link is still just the heading's text, so it's named by the heading alone, while a pseudo-element of the link covers the whole card, so pressing anywhere on the card follows it.
Project
Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.
<Card
heading="Analytical Engine"
href="/projects/analytical-engine"
eyebrow="Project"
media={<img src="/projects/analytical-engine.jpg" alt="" />}
actions={
<>
<Button size="sm" variant="outline" aria-pressed={saved} onClick={() => setSaved(!saved)}>Save</Button>
<Button size="sm" variant="ghost" onClick={share}>Share…</Button>
</>
}
footer="By Charles Babbage"
>
<p>Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.</p>
</Card>
<a> would make all of its text the link's name, read out every time someone tabs to it.<a href>, so it works like every other link. People can open it in a new tab with Ctrl or Cmd, or with the middle mouse button, copy its address, and see where it goes before they press it. A card with a click handler or role="link" loses all of that, and keyboard users can't reach it at all unless it has a tabindex.actions, and sit above the stretched link, so pressing one runs it rather than following the link. Tab reaches them right after the link. HTML doesn't allow a button inside a link, which is one more reason the card itself is never the link.An image that only illustrates the heading, like a photo of the product it names, gets an empty alt="", which tells screen readers to skip it. If its alt text repeated the heading, people would hear the same thing twice. An image that shows something the text doesn't, like a chart or a floor plan, needs alt text that says what it shows (WCAG 1.1.1).
When a card's link has focus, the whole card shows an outline in the same focus colour as every other component (WCAG 2.4.7). It's drawn by the link's pseudo-element, which covers the card, so it goes around the whole card rather than the heading's text, which is where people can press. Like the Button's, it's at least 2px thick and drawn outside the border, and we recommend 2px for both. An action shows its own focus ring instead, so only one thing on the page ever looks focused.
You'll also want to keep focused cards clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12).
| Key | Card |
|---|---|
Tab, Shift+Tab | Moves focus to the card's link, if it has one, and then on to its actions |
Enter | Follows the link |
A card has no keys of its own. It's a link and some Buttons, which Tab reaches in the order they come in, like on the rest of the page. We recommend not adding arrow keys to move between cards, since people don't expect them on a list of links, and they'd have to find out that they work. A list where the arrow keys do move between items, like a feed that loads more as people scroll, is a pattern of its own, which the APG calls a Feed.
The heading comes first in the card's DOM, before the eyebrow and the media, while CSS shows the media and the eyebrow above it. People who move from heading to heading land at the start of the card, and hear what it's about before anything else. With an eyebrow or an image before the heading in the DOM, they'd land past them, and miss them.
Moving content around with CSS is only safe for content people can't focus. Tab follows the DOM, so if the media held a link or a button, focus would go to the heading's link first and then jump back up to the media. Keep the media an image, and put anything people can press in the body, the actions or the footer.
When a card is a link, its heading is the link's name, so it's best to write it like one: the name of the thing the card is about, rather than "Read more" or "Learn more". People who list the links on a page see the names alone, and five links called "Read more" don't say where any of them go (WCAG 2.4.4). For the same reason, we recommend not adding a separate "Read more" link next to the stretched one, which would only be a second way to the same place.
The link holds the heading's text and nothing else. HTML doesn't allow a link or a button inside a link, so nothing defines what browsers and screen readers should do with one. Actions go in actions, which is outside the link, and the stretched link only covers them on screen, where the Card lifts them above it.
A card that's a link shows that it is when people point at it or tab to it. On hover, its border gets stronger and its heading is underlined, which tells people the whole card does something before they press it. On focus, it shows its focus ring. The pointer over the card is the link's own, so people see the hand they'd see over any other link.
A card can have actions of its own, like Save or Share, whether it's a link or not. They're Buttons, so they keep their names, their states and their focus rings, and pressing one runs it, even on a card that's a link. Once an action has done something, like saving the project, its state shows on the Button, like aria-pressed on a Save toggle, rather than on the card.
Not every card has an image, and an image can fail to load. The media keeps its space whatever its image does, with aspect-ratio, so cards in a row stay lined up, and the page doesn't jump when an image arrives late. While it loads, or if it fails, the space shows a faint tint of the text colour instead of a gap. If some cards in a list have no image at all, leave the media out of them, rather than showing an empty box.
While cards load, hold their place with placeholders the same size as the cards, so the page doesn't jump around when they arrive. A Skeleton, on our Roadmap, will do that for you. If the cards load because of something people did, like a search, say how many arrived in a status message, since a screen reader user won't see them appear.
Headings and text wrap rather than being cut off, and the card grows to fit them. We recommend not cutting headings off with an ellipsis or a line clamp. People can't read the rest, a screen reader still reads all of it, so the two disagree, and a translated heading is often longer than the original.
@media (hover: hover)), otherwise a tap would leave the card looking hovered.The Card doesn't move. If you'd like a card to lift or grow on hover, wrap the animation in @media (prefers-reduced-motion: no-preference), so it only runs for people who haven't asked for less motion (WCAG 2.3.3). Keep in mind that a card that grows can cover the ones next to it.
When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the border is drawn in the text colour at full strength, rather than at 20%. The text, the eyebrow and the footer already pass 4.5:1, so they stay as they are.
Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows. The Card's border stays, since the system draws it in a colour of its own, focus is drawn with outline, in Highlight, and the media's <img> keeps its image. That's why the Card draws its boundary with a border rather than a shadow, which these themes would drop.
The tokens use light-dark(), which follows color-scheme, so the Card adapts to your light and dark themes on its own. If you give it a background, use a token that does the same, and check the text against it in both.
Nothing in the Card has a fixed height, so it grows with its text instead of clipping it, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). Long words, like a web address, wrap rather than overflow the card. Only the media keeps its shape, since it's sized by its width and its aspect ratio.
The Card uses logical properties, so it mirrors itself in right-to-left languages, with its actions starting from the right:
مشروع
تصميم تشارلز باباج لحاسوب ميكانيكي عام الغرض، وُصف لأول مرة عام 1837.
<Card
dir="rtl"
lang="ar"
heading="المحرك التحليلي"
href="/ar/projects/analytical-engine"
eyebrow="مشروع"
media={<img src="/projects/analytical-engine.jpg" alt="" />}
actions={<Button size="sm" variant="outline">حفظ</Button>}
>
<p>تصميم تشارلز باباج لحاسوب ميكانيكي عام الغرض، وُصف لأول مرة عام 1837.</p>
</Card>
dir and lang, on <html>, and the Card follows it. Set them on a card when its language differs from the page's, like one card in a list of translations (WCAG 3.1.2).Next, let's look at how developers use the Card. It has one API, with the same names as every other component, it's typed end to end, and it needs no script at all, since everything it does is HTML and CSS.
import { Card } from "@/components/Card";
export function Projects({ projects }: { projects: Project[] }) {
return (
<>
<h2>Projects</h2>
<ul className="projects">
{projects.map((project) => (
<Card key={project.id} as="li" heading={project.name} href={`/projects/${project.id}`}>
<p>{project.summary}</p>
</Card>
))}
</ul>
</>
);
}
Once you've learned the Button's and the Input's APIs, you already know the Card's href and actions. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
href | Prop | The component is a link, and this is where it goes |
default | Slot | The main content, between the component's tags |
actions | Slot | Buttons that act on the component, after its main content |
The rest of the Card's names are its own, and each one says what it is:
heading is the heading, and headingLevel its level. We didn't call it title, which is an HTML attribute that shows a tooltip, so in Angular, Astro or plain HTML, where an attribute stays on the element, every card would grow one. label is for controls, which a card isn't.as picks the element, as it does in many component libraries. We recommend article as the default, since it's what most cards are.media, eyebrow and footer are named after the parts they fill, so the slot and the data-slot it renders have the same name.The Card also leaves a few props out, on purpose:
variant or color. A card has no intent of its own, unlike a Button, and its look comes from its variables, which you set from a class.disabled. A card isn't a control, so there's nothing to disable. If its link isn't available, like a project that's been archived, leave out the href and say why in the body.selected. A card people pick, like a plan, is a choice, so it belongs to a form control: a Radio Group, on our Roadmap, when people pick one, or a Checkbox Group when they pick any number, each with the keyboard and the states that a choice needs.onClick. The Card never listens for clicks, since its link does that job, and its actions are Buttons with listeners of their own.Props and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like headingLevel={1}, which would give a card the page's own heading level.
| Prop | Type | Default | Description |
|---|---|---|---|
as | "article" | "section" | "div" | "li" | "article" | The element it renders |
heading | string | The heading, when it's plain text. The heading slot takes precedence | |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | The heading's level, which fits the page's outline |
href | string | Links the card through its heading, stretched over the whole card |
The Card doesn't fire events of its own. Its link and its actions fire theirs, like click, which you listen to on them.
| Slot | Description |
|---|---|
default | The body, like a sentence or two and some details |
heading | The heading, when it's more than plain text, or your 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 |
We also export CardProps, which comes in handy for wrappers and data-driven pages:
import type { CardProps } from "@/components/Card";
const featured: CardProps = { heading: "Analytical Engine", headingLevel: 2, href: "/projects/analytical-engine" };
The root element is the card itself, the <article> or whichever element as picks, so everything you pass lands directly on it. That includes classes, styles, id, dir, lang, ARIA attributes and event listeners. A ref to the Card reaches that same element, so there's no wrapper to reach through.
If you'd like an <article> to have a name of its own, give the heading's text an id, through the heading slot, and point the card's aria-labelledby at it. Keep in mind that a <section> with a name becomes a region landmark, which people can jump to, so only name a section card when it's one of the main parts of the page.
href renders a plain <a>. For links that go through your router, put your router's link in the heading slot instead, and the Card stretches it over the card just the same, since it stretches any link in its heading. Routers that handle every link on the page only need the href:
import { Link } from "react-router";
<Card heading={<Link to="/projects/analytical-engine">Analytical Engine</Link>}>
<p>A general-purpose mechanical computer.</p>
</Card>
Since the Card doesn't know anything about your router, it works with any of them.
sm Buttons, ranked outline or ghost, so a page of cards doesn't fill up with primary actions.actions, rather than a stretched link, since a link should always go somewhere.The Card doesn't keep any state of its own. Everything about it is a prop, an attribute or an element that you control:
| What | How you control it |
|---|---|
| Element | as |
| Heading level | headingLevel |
| Link | href, or a link in the heading slot |
| Hover, focus | The browser, from the link |
| Focus | The link's own focus(), through a ref to the card or to your link |
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.
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();
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.
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>
as names, and the heading's level always the one headingLevel names, so neither is guessed from where the card is on the page.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.Every rule in the checklist can be checked, and about a third of them automatically.
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.
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.
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.
Here's every rule in one place, along with where it comes from and how to check it. A card meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.
The scope says who meets a rule. Component rules are met by the Card itself, so they're the ones to check when you build or review one. Usage rules are met by the pages that use it, like describing its images, so they're the ones to check when you review a screen. Both rules need the two to work together, like its heading: the Card renders one at the level you give it, as long as you give it a heading that says what the card is about.
The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and one comes from HTML. The rest are Open Components rules, which are our own, like putting the heading first in the DOM. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like keeping cards still for people who ask for less motion, and a rule that asks for more than its criterion says beyond, like the stretched link, where WCAG would let a card be a link in other ways.
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
card/contrast | Must | Component | The text, the eyebrow and the footer included, has a contrast of 4.5:1 against the card, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast, contrast checker |
card/text-below-media | Should | Component | Text sits below the media rather than on top of it, so its contrast doesn't depend on the image | Open Components | Review |
card/focus-visible | Must | Component | When its link has keyboard focus, an outline at least 2px thick shows around the whole card, outside its border | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
card/resizable | Must | Component | Nothing in the card has a fixed height, so it grows with its text, and long words wrap instead of overflowing it | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
card/stable-size | Must | Component | No state changes the card's size or position | Open Components | Visual regression test |
card/media-ratio | Should | Component | The media keeps its space with an aspect ratio, so cards line up and the page doesn't jump as images load | Open Components | Visual regression test |
card/state-selectors | Should | Component | Hover and focus are styled from the link, so the card only looks interactive where pressing it follows the link | Open Components | Review |
card/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
card/forced-colors | Must | Component | The border and the focus ring stay visible in forced colours mode | Open Components | Emulation |
card/more-contrast | Should | Component | The border is drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
card/reduced-motion | Must | Usage | Nothing moves on hover or focus when prefers-reduced-motion: reduce is set | WCAG 2.3.3 (AAA) | Emulation |
card/reflow | Must | Usage | Cards in a grid get narrower rather than overflow, so the page never scrolls sideways at 320px wide | WCAG 1.4.10 (AA) | Emulation |
card/quiet-actions | Should | Usage | There are at most two actions, neither of them a solid Button | Open Components | Review |
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
card/semantic-element | Must | Component | It's an <article> by default, or a <section>, <div> or <li> with as, and never a link or a button | Open Components, beyond WCAG 1.3.1 (A) | Unit test |
card/list-of-cards | Should | Usage | A set of cards is a list, with each card an <li> | WCAG 1.3.1 (A) | Review |
card/heading | Must | Both | Every card has a heading that says what it's about, at the level that fits the page's outline | WCAG 1.3.1 (A), 2.4.6 (AA) | Unit test, review |
card/heading-first | Should | Component | The heading comes first in the DOM, and the media and the eyebrow are shown above it with CSS alone | Open Components | Unit test |
card/stretched-link | Must | Component | A card that's a link has one link, in its heading, stretched over the card with a pseudo-element, and never a wrapping <a>, role="link" or a click handler on the card | Open Components, beyond WCAG 2.1.1, 4.1.2 (A) | Unit test |
card/link-name | Must | Component | The link is named by the heading alone | WCAG 2.4.4 (A) | Unit test |
card/link-purpose | Should | Usage | Headings say where the link goes, differ from card to card, and there's no separate "Read more" link | WCAG 2.4.4 (A), 2.4.9 (AAA) | Review |
card/actions-above | Must | Component | Actions and other links in the card sit outside the stretched link and above it, so pressing one runs it | Open Components | Unit test, review |
card/no-nesting | Must | Both | The link holds the heading's text alone, with nothing interactive inside it | HTML | Unit test |
card/keyboard | Must | Component | Tab reaches the card's link once and then its actions, and Enter follows the link | WCAG 2.1.1, 2.4.3 (A) | Unit test |
card/media-alt | Must | Usage | An image that only illustrates the heading has alt="", and one that shows more has alt text that says what | WCAG 1.1.1 (A) | axe image-alt, review |
card/static-media | Must | Usage | The media holds nothing people can focus, since it's shown above the heading but comes after it in the DOM | WCAG 1.3.2, 2.4.3 (A) | Keyboard |
card/dom-order | Must | Usage | Cards come in the same order in the DOM as on screen, without order, row-reverse or a dense grid | Open Components, beyond WCAG 1.3.2, 2.4.3 (A) | Keyboard |
card/focus-not-obscured | Must | Usage | Sticky headers and footers never cover a focused card, for example thanks to scroll-padding | WCAG 2.4.11 (AA), 2.4.12 (AAA) | Keyboard |
card/no-truncation | Should | Usage | Headings aren't cut off with an ellipsis or a line clamp | Open Components | Review |
card/announce-results | Should | Usage | Cards that load after something people did, like a search, are announced with a status message | WCAG 4.1.3 (AA) | Screen reader |
card/mirrors-in-rtl | Should | Component | It uses logical properties, so it mirrors itself in right-to-left layouts | Open Components | Review |
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
card/shared-vocabulary | Must | Component | It takes href like the Button and actions like the Input, and names its other slots after its parts: heading, eyebrow, media and footer | Open Components | Review |
card/no-attribute-names | Should | Component | No prop shares its name with an HTML attribute that does something else, like title | Open Components | Review |
card/typed | Must | Component | Props and slots are typed, with unions rather than string or number, so headingLevel only takes 2 to 6 | Open Components | Type check |
card/root-element | Must | Component | The root element is the card itself, so attributes, listeners and refs land on it | Open Components | Unit test |
card/router-links | Should | Component | A link in the heading slot, like a router's, is stretched over the card like the one href renders | Open Components | Unit test, review |
card/stateless | Should | Component | It keeps no state of its own | Open Components | Review |
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
card/role-and-name | Must | Component | It 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-dom | Must | Component | The same props render the same markup, with no generated IDs, and parts only render when they're given | Open Components | Unit test |
card/data-attributes | Should | Component | Parts are marked with data-slot | Open Components | Unit test |
Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too, and its examples put Buttons in actions.
The tests are in Card.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.
<script setup lang="ts">
export interface CardProps {
/** The element it renders: `li` in a list of cards, or `section` or `div` for a card that isn't self-contained. */
as?: "article" | "section" | "div" | "li";
/** The heading, when it's plain text. The `heading` slot takes precedence. It isn't `title`, which is an HTML attribute that shows a tooltip. */
heading?: string;
/** The heading's level, which fits the page's outline: 3 under a section's `<h2>`. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Links the card through its heading, and stretches the link over the whole card. */
href?: string;
}
const { as = "article", heading, headingLevel = 3, href } = defineProps<CardProps>();
defineSlots<{
/** The body, like a sentence or two and some details. */
default?: () => unknown;
/** The heading, when it's more than plain text, or your router's link. A link here stretches over the card too. */
heading?: () => unknown;
/** A short line shown above the heading, like a category, which comes after it in the DOM. */
eyebrow?: () => unknown;
/** An image, shown at the top, which comes after the heading in the DOM. */
media?: () => unknown;
/** Buttons that act on the card, like Save, which sit above the stretched link and stay reachable with Tab. */
actions?: () => unknown;
/** A last line of details about the card, like who made it or when. */
footer?: () => unknown;
}>();
</script>
<template>
<!-- The heading comes first in the DOM, so heading navigation lands at the start of
the card. The media and the eyebrow come after it, and CSS shows them above it. -->
<component :is="as" class="card">
<div data-slot="header">
<component :is="`h${headingLevel}`" data-slot="heading">
<a v-if="href" data-slot="link" :href="href">
<slot name="heading">{{ heading }}</slot>
</a>
<slot v-else name="heading">{{ heading }}</slot>
</component>
<p v-if="$slots.eyebrow" data-slot="eyebrow">
<slot name="eyebrow" />
</p>
</div>
<div v-if="$slots.media" data-slot="media">
<slot name="media" />
</div>
<div v-if="$slots.default" data-slot="body">
<slot />
</div>
<div v-if="$slots.actions" data-slot="actions">
<slot name="actions" />
</div>
<div v-if="$slots.footer" data-slot="footer">
<slot name="footer" />
</div>
</component>
</template>
<style scoped>
/* The same layer order as Tailwind CSS. The card's styles go in `components`,
so they win over the resets in `base`, and give way to utilities and to any
style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;
@layer components {
.card {
--card--padding: 1.25rem;
--card--gap: 0.75rem;
--card--radius: 0.75rem;
/* The card's border is decoration: its heading says what it is, so it doesn't need 3:1. */
--card--border-color: color-mix(in srgb, currentColor 20%, transparent);
--card--background: transparent;
--card--media--aspect-ratio: 16 / 9;
/* The containing block of the stretched link. */
position: relative;
display: flex;
flex-direction: column;
gap: var(--card--gap);
box-sizing: border-box;
/* In a grid or a flex row, it can get narrower than its content, rather than overflow. */
min-width: 0;
margin: 0;
padding: var(--card--padding);
border: 1px solid var(--card--border-color);
border-radius: var(--card--radius);
background-color: var(--card--background);
color: var(--color--neutral-text);
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
/* A list item's marker, when it's an `li`. */
list-style: none;
/* Long words, like a web address, wrap rather than overflow the card. */
overflow-wrap: break-word;
}
/* Parts. They have no borders of their own: a line between two of them is a separator's
job, which a page adds where it needs one. No height is fixed anywhere, so the card
grows with its text. */
.card > [data-slot="header"] {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* The body takes the spare height, so actions and footers line up across a row of
cards of one height. A card without a body gives it to its header instead. */
.card > [data-slot="body"],
.card:not(:has(> [data-slot="body"])) > [data-slot="header"] {
flex-grow: 1;
}
.card > [data-slot="header"] > [data-slot="heading"] {
margin: 0;
color: inherit;
font-size: 1rem;
font-weight: 600;
line-height: 1.375;
}
/* Shown above the heading, while screen readers read it after the heading. */
.card > [data-slot="header"] > [data-slot="eyebrow"] {
order: -1;
margin: 0;
/* The text colour at 70%, which has 4.5:1 against the page. */
color: color-mix(in srgb, currentColor 70%, transparent);
font-size: 0.75rem;
font-weight: 500;
}
/* The media fills the top of the card, edge to edge, while it comes after the
heading in the DOM. Its aspect ratio holds its space while the image loads. */
.card > [data-slot="media"] {
order: -1;
margin-block-start: calc(-1 * var(--card--padding));
margin-inline: calc(-1 * var(--card--padding));
overflow: hidden;
border-start-start-radius: calc(var(--card--radius) - 1px);
border-start-end-radius: calc(var(--card--radius) - 1px);
background-color: color-mix(in srgb, currentColor 8%, transparent);
aspect-ratio: var(--card--media--aspect-ratio);
}
.card > [data-slot="media"] > :slotted(:is(img, picture, video, svg)) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.card > [data-slot="actions"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.card > [data-slot="footer"] {
/* The text colour at 70%, which has 4.5:1 against the page. */
color: color-mix(in srgb, currentColor 70%, transparent);
font-size: 0.75rem;
}
/* A link in the heading, the Card's own or one in the `heading` slot, stretches over
the card with a pseudo-element, so pressing anywhere on the card follows it,
while the link's name stays the heading alone. */
.card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]) {
color: inherit;
text-decoration: none;
outline: none;
}
.card > [data-slot="header"] > [data-slot="heading"] :deep(a[href])::after {
content: "";
position: absolute;
/* Over the border too, so the focus ring sits where it would around the card. */
inset: -1px;
border-radius: var(--card--radius);
}
/* Everything else people can press sits above the stretched link, so it does what
it says rather than follow the link. */
.card > [data-slot="header"] > [data-slot="eyebrow"] :deep(a[href]),
.card > :is([data-slot="body"], [data-slot="actions"], [data-slot="footer"]) :deep(:is(a[href], button, input, select, textarea, summary, [tabindex])) {
position: relative;
z-index: 1;
}
/* States. The card only has any when it's a link. Focus is drawn around the whole
card, by the link's pseudo-element, while an action shows its own focus ring. */
.card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:focus-visible)::after {
outline: 2px solid var(--color--focus);
outline-offset: 2px;
}
@media (hover: hover) {
/* Only while the pointer is over the link, so not over an action. */
.card:has(> [data-slot="header"] > [data-slot="heading"] a[href]:hover) {
--card--border-color: color-mix(in srgb, currentColor 45%, transparent);
}
.card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:hover) {
text-decoration-line: underline;
text-underline-offset: 0.2em;
}
}
/* With more contrast, the border is drawn in the text colour at full strength, on
hover too, so this repeats the hover rule's selector, which it comes after. */
@media (prefers-contrast: more) {
.card,
.card:has(> [data-slot="header"] > [data-slot="heading"] a[href]:hover) {
--card--border-color: currentColor;
}
}
/* Forced colors mode keeps the border in a system colour, and the focus ring in Highlight. */
@media (forced-colors: active) {
.card > [data-slot="header"] > [data-slot="heading"] :deep(a[href]:focus-visible)::after {
outline-color: Highlight;
}
}
}
</style>
import { render, screen, within } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { h, ref } from "vue";
import Card from "./Card.vue";
const image = () => h("img", { src: "engine.png", alt: "" });
const button = (name: string, onClick?: () => void) => h("button", { type: "button", onClick }, name);
describe("Card", () => {
it("is an article, found by its heading", () => {
render(Card, { props: { heading: "Analytical Engine" }, slots: { default: () => "A mechanical computer." } });
const card = screen.getByRole("article");
expect(within(card).getByRole("heading", { name: "Analytical Engine" })).toBeInTheDocument();
expect(card).toHaveTextContent("A mechanical computer.");
});
it("renders a section, a div or a list item with as", () => {
const { container } = render({
render: () => [
h(Card, { as: "section", heading: "Section" }),
h(Card, { as: "div", heading: "Div" }),
h("ul", [h(Card, { as: "li", heading: "List item" })]),
],
});
expect(container.querySelector("section.card")).toBeInTheDocument();
expect(container.querySelector("div.card")).toBeInTheDocument();
expect(screen.getByRole("listitem")).toHaveClass("card");
expect(screen.queryByRole("article")).not.toBeInTheDocument();
});
it("has a heading at the level it's given, and level 3 by default", () => {
render({
render: () => [h(Card, { heading: "Default" }), h(Card, { heading: "Second", headingLevel: 2 })],
});
expect(screen.getByRole("heading", { name: "Default" })).toHaveProperty("tagName", "H3");
expect(screen.getByRole("heading", { name: "Second", level: 2 })).toBeInTheDocument();
});
it("puts its heading first, before its eyebrow and media, whatever the order on screen", () => {
const { container } = render(Card, {
props: { heading: "Analytical Engine" },
slots: { eyebrow: () => "Project", media: image, default: () => "A mechanical computer." },
});
const text = [...container.querySelectorAll("[data-slot='heading'], [data-slot='eyebrow'], img")];
expect(text.map((element) => element.getAttribute("data-slot") ?? element.tagName)).toEqual(["heading", "eyebrow", "IMG"]);
});
it("links through its heading alone, without wrapping the card in a link or listening for clicks on it", () => {
// Testing Library may listen on the root of whatever it renders, so the card's click
// listeners are compared with those on a plain <article> rendered the same way.
const listen = vi.spyOn(Element.prototype, "addEventListener");
const clicks = (root: Element) =>
listen.mock.calls.filter(([type], i) => type === "click" && listen.mock.contexts[i] === root).length;
const plain = render({ render: () => h("article") }).container.firstElementChild!;
const { container } = render(Card, {
props: { heading: "Analytical Engine", href: "/projects/analytical-engine" },
slots: { eyebrow: () => "Project", default: () => "A mechanical computer." },
});
expect(clicks(container.firstElementChild!)).toBe(clicks(plain));
listen.mockRestore();
const link = screen.getByRole("link", { name: "Analytical Engine" });
expect(link).toHaveAttribute("href", "/projects/analytical-engine");
expect(screen.getAllByRole("link")).toHaveLength(1);
expect(link).toHaveTextContent(/^Analytical Engine$/);
expect(screen.getByRole("heading", { name: "Analytical Engine" })).toContainElement(link);
const card = container.firstElementChild!;
expect(card.tagName).toBe("ARTICLE");
expect(card).not.toHaveAttribute("role");
expect(card).not.toHaveAttribute("tabindex");
});
it("stretches a link from the heading slot, like a router's, as it does its own", () => {
render(Card, {
slots: { heading: () => h("a", { href: "/projects/analytical-engine" }, "Analytical Engine") },
});
const link = screen.getByRole("link", { name: "Analytical Engine" });
expect(screen.getAllByRole("link")).toHaveLength(1);
expect(screen.getByRole("heading", { name: "Analytical Engine" })).toContainElement(link);
});
it("reaches its link, then its actions, with Tab, and follows the link on Enter", async () => {
const user = userEvent.setup();
// jsdom doesn't navigate, so this stands in for the browser following the link.
const onNavigate = vi.fn((event: Event) => event.preventDefault());
document.addEventListener("click", onNavigate);
render(Card, {
props: { heading: "Analytical Engine", href: "/projects/analytical-engine" },
slots: { actions: () => [button("Share"), button("Save")] },
});
await user.tab();
expect(screen.getByRole("link", { name: "Analytical Engine" })).toHaveFocus();
await user.keyboard("{Enter}");
document.removeEventListener("click", onNavigate);
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate.mock.calls[0]![0].target).toBe(screen.getByRole("link"));
await user.tab();
expect(screen.getByRole("button", { name: "Share" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Save" })).toHaveFocus();
});
it("keeps its actions out of the link, so pressing one runs it rather than following the link", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(Card, {
props: { heading: "Analytical Engine", href: "/projects/analytical-engine" },
slots: { actions: () => button("Save", onSave) },
});
const save = screen.getByRole("button", { name: "Save" });
expect(screen.getByRole("link")).not.toContainElement(save);
await user.click(save);
expect(onSave).toHaveBeenCalledTimes(1);
});
it("puts attributes, listeners and refs on its root", async () => {
const user = userEvent.setup();
const onFocusin = vi.fn();
const card = ref<{ $el: HTMLElement }>();
render({
render: () =>
h(
Card,
{ ref: card, id: "engine", class: "featured", "aria-labelledby": "engine-heading", href: "/projects/analytical-engine", onFocusin },
{ heading: () => h("span", { id: "engine-heading" }, "Analytical Engine") },
),
});
const root = screen.getByRole("article", { name: "Analytical Engine" });
expect(root).toHaveAttribute("id", "engine");
expect(root).toHaveClass("card", "featured");
await user.tab();
expect(onFocusin).toHaveBeenCalled();
expect(card.value?.$el).toBe(root);
});
it("renders the same markup every time, with its parts in a fixed order", () => {
const options = {
props: { heading: "Analytical Engine", href: "/projects/analytical-engine" },
slots: {
eyebrow: () => "Project",
media: image,
default: () => "A mechanical computer.",
actions: () => button("Save"),
footer: () => "By Charles Babbage",
},
};
const first = render(Card, options).container.innerHTML;
const { container } = render(Card, options);
expect(container.innerHTML).toBe(first);
const parts = [...container.querySelectorAll("[data-slot]")].map((part) => part.getAttribute("data-slot"));
expect(parts).toEqual(["header", "heading", "link", "eyebrow", "media", "body", "actions", "footer"]);
expect(container.innerHTML).not.toMatch(/\bid=/);
});
it("only renders the parts it's given", () => {
const { container } = render(Card, { props: { heading: "Analytical Engine" } });
const parts = [...container.querySelectorAll("[data-slot]")].map((part) => part.getAttribute("data-slot"));
expect(parts).toEqual(["header", "heading"]);
});
});
A card summarises one thing, so people can scan a few of them and pick one. For anything else, another component will do a better job:
| When people need to… | Use |
|---|---|
| Scan a few things and pick one, like projects or products | Cards, in a list |
| Compare many things by their details, like prices and dates | Table |
| Pick one option, like a plan, as part of a form | Radio Group, which can look like a set of cards |
| Be told something important, like an error | Alert |
| Read through a stream that loads more as they scroll | A list of cards, built on the APG's Feed pattern |
The Table, the Radio Group and the Alert are on our Roadmap.
Ctrl or Cmd, or the middle mouse button, which is one of the things a real link gives them.<article>, a real heading and a real link, which work before the page hydrates, and even without JavaScript at all. Only your actions, like Save, wait for your script.Agentic Experience
How agents read, build and check a button, from its accessibility tree to its contract, its DOM and its tests.
Checkbox
How to build a checkbox that people can check with a click on its label, that says what it means and when it's mixed, that forms send the way they expect, and that agents can check.