Components

Card

How to build a card that groups what people need to know about one thing, that keyboard and screen reader users can move through, and that agents can check.

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:

  • User Interface (UI) - how a card looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

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.

A general-purpose mechanical computer, first described in 1837.

By Charles Babbage
<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>

At a glance

AspectCard
Element<article>, or a <section>, <div> or <li> with as
Rolearticle, or listitem as an <li>
NameIts heading, which also names its link when the card is one
KeyboardTab to its link, then to its actions, and Enter to follow the link
StatesHover and focus, when it's a link
PartsMedia, a header with the heading and an eyebrow, body, actions and footer, of which only the header and its heading are always there
PatternNone in the APG, since an <article> and a link cover it
WCAG 2.21.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

User Interface (UI)

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.

Anatomy

  1. Container — The root, an <article> by default, which draws the border and holds the parts.
  2. Media — Optional. An image, shown at the top, edge to edge.
  3. Eyebrow — Optional. A short line in the header, shown above the heading, like a category.
  4. Heading — The header's heading, which says what the card is about. When the card is a link, the link is in here.
  5. Body — Optional. A sentence or two, and any details, like a date or a price.
  6. Actions — Optional. Buttons that act on the card, like Save, right after the body.
  7. Footer — Optional. A last line of details about the card, like who made it or when.
  8. Focus ring — An outline around the whole card when its link has focus, drawn outside it so nothing moves.

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.

Content

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.

Difference Engine

Babbage's machine for working out tables of numbers, by the method of differences.

Analytical Engine

Project

Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.

By Charles Babbage
<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>
  • Keep the heading short, and make it the thing people are choosing between, like the project's name, so they can tell cards apart by their headings alone.
  • The eyebrow is for a word or two that sorts the card, like a category or a date. Anything longer belongs in the body, where it doesn't push the heading down.
  • An image that only illustrates the heading, like a product photo next to the product's name, has an empty alt, so screen readers skip it rather than read the name twice. You'll find more under Describe the media.
  • Keep the body to a sentence or two. A card is a summary of a page, and people go to that page for the rest.
  • We recommend at most two actions, ranked like any other Buttons, with an outline or ghost Button rather than a solid one, so a page of cards doesn't fill up with primary actions.
  • The footer is for details about the card rather than about its subject, like who made it, when it was updated or how many comments it has. Its text is smaller and lighter than the body's, at 4.5:1 like the eyebrow, so it doesn't compete with the body.
  • The parts have no borders between them. A line between two of them is a separator, which you add only where it helps, like above a footer of links. Inside a card it's decorative, since the heading already marks the structure, so draw it with an empty element's border, or use <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)).

Layout

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;
}
  • The 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).
  • Cards in a row of a grid stretch to the same height, and the body takes the spare height, or the header in a card without a body, so the actions and the footers line up from card to card.
  • Keep the cards in the same order in the DOM as on screen, and don't reorder them with 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).
  • Safari has dropped the list role from lists with list-style: none, so VoiceOver doesn't say how many items there are. If that matters to you, add role="list" to the <ul>.

States

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.

Difference Engine

A machine for working out tables of numbers.

Without a link, it has no states
A link (try hovering or tabbing to it)
<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>
StateSelectorRecommended 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
Focusa[href]:focus-visible::after, the link's pseudo-elementA 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.

Tokens

The card reads two theme tokens, which every component shares:

TokenUsed for
--color--neutral-textThe text, and mixed into the border, the eyebrow, the footer and the media's placeholder
--color--focusThe 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:

ProjectCard.tsx
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>
ProjectCard.module.css
.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.

User Experience (UX)

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.

Accessible

Use the right element

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:

ElementasWhen
<article>The defaultThe 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.

Give every card a heading

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.

  • Every card has a heading that says what it's about, like the project's name (WCAG 1.3.1, 2.4.6). The Card renders it as an <h2> to <h6>, so it's a heading for assistive technologies too, rather than bold text.
  • Pick the level that fits the page's outline, with 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.
  • Give each card in a list a different heading, so people can tell them apart when they list the page's headings or links.

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.

Charles Babbage's design for a general-purpose mechanical computer, first described in 1837.

By Charles Babbage
<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>
  • The link's name is the heading, like "Analytical Engine", so screen readers announce a short name that says where it goes, and voice control users can say "click Analytical Engine" (WCAG 2.4.4, 2.5.3). Wrapping the whole card in an <a> would make all of its text the link's name, read out every time someone tabs to it.
  • It's a real <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, like Save, stay outside the link, in 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.
  • A link inside the body or the eyebrow, like a link to the project's author, sits above the stretched link too, so it goes where it says. Keep them few, though, since a card with many links in it is harder to press in the right place.

Describe the media

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).

Contrast and size

  • The text needs a contrast of at least 4.5:1 with the card's background, and so do the eyebrow and the footer, since people need to read them too (WCAG 1.4.3). Text over an image is hard to keep at 4.5:1, which is why the Card puts its text below the media rather than on top of it.
  • The border doesn't need 3:1, since it doesn't tell people anything the heading doesn't (WCAG 1.4.11). The focus ring does, against the page.
  • A card that's a link is a large target already, since the whole card follows the link. Its actions are Buttons, which are at least 24 × 24px (WCAG 2.5.8), with a hit area of 44 × 44px on touch screens. Leave a gap between them, as the actions do, so a finger doesn't press the one next to it.

Show focus

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).

Predictable

Keyboard

KeyCard
Tab, Shift+TabMoves focus to the card's link, if it has one, and then on to its actions
EnterFollows 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.

Put the heading first

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.

Write headings that say where they go

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.

Don't nest interactive elements

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.

Every state

Linked

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.

With actions

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.

Without media

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.

Loading

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.

Long content

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.

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the card looking hovered.
  • On touch screens, the whole card follows its link, so it's an easy target, while its actions are Buttons with hit areas of their own, at least 44 × 44px.
  • The stretched link covers the card's text, so pressing and dragging on it drags the link rather than selecting the text. If people need to copy something from a card, like a code or an address, put it on the page the card links to, or leave that card without a link.

Motion

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.

More contrast

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.

Forced colours

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.

Colour scheme

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.

Text size and spacing

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.

Languages and direction

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>
  • In right-to-left layouts, it's usually the page that sets 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).
  • Images don't mirror themselves, which is usually right, since a photo flipped left to right looks wrong. Only flip an image that points along the reading direction, like an arrow.

Developer Experience (DX)

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>
    </>
  );
}

Consistent

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:

NameKindWhat it means
hrefPropThe component is a link, and this is where it goes
defaultSlotThe main content, between the component's tags
actionsSlotButtons 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:

  • There's no 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.
  • There's no 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.
  • There's no 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.
  • There's no onClick. The Card never listens for clicks, since its link does that job, and its actions are Buttons with listeners of their own.

Type-safe

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.

PropTypeDefaultDescription
as"article" | "section" | "div" | "li""article"The element it renders
headingstringThe heading, when it's plain text. The heading slot takes precedence
headingLevel2 | 3 | 4 | 5 | 63The heading's level, which fits the page's outline
hrefstringLinks 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.

SlotDescription
defaultThe body, like a sentence or two and some details
headingThe heading, when it's more than plain text, or your router's link, which is stretched too
eyebrowA short line shown above the heading, like a category, which comes after it in the DOM
mediaAn image, shown at the top, which comes after the heading in the DOM
actionsButtons that act on the card, like Save, which sit above the stretched link
footerA 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" };

Composable

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.

With other components

  • Actions are Buttons, which keep their names, states and focus rings, and sit above the stretched link. We recommend sm Buttons, ranked outline or ghost, so a page of cards doesn't fill up with primary actions.
  • A Spinner doesn't belong in a card while it loads. A Skeleton, on our Roadmap, will hold the card's shape instead.
  • A Badge, also on our Roadmap, fits in the eyebrow, to mark a status like "New".
  • A card that opens a Dialog rather than a page gets a Button for it in actions, rather than a stretched link, since a link should always go somewhere.

Controllable

The Card doesn't keep any state of its own. Everything about it is a prop, an attribute or an element that you control:

WhatHow you control it
Elementas
Heading levelheadingLevel
Linkhref, or a link in the heading slot
Hover, focusThe browser, from the link
FocusThe link's own focus(), through a ref to the card or to your link

Agentic Experience (AX)

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.

Checklist

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.

UI rules

RuleLevelScopeRequirementBasisCheck
card/contrastMustComponentThe 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 pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast, contrast checker
card/text-below-mediaShouldComponentText sits below the media rather than on top of it, so its contrast doesn't depend on the imageOpen ComponentsReview
card/focus-visibleMustComponentWhen its link has keyboard focus, an outline at least 2px thick shows around the whole card, outside its borderWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
card/resizableMustComponentNothing in the card has a fixed height, so it grows with its text, and long words wrap instead of overflowing itOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
card/stable-sizeMustComponentNo state changes the card's size or positionOpen ComponentsVisual regression test
card/media-ratioShouldComponentThe media keeps its space with an aspect ratio, so cards line up and the page doesn't jump as images loadOpen ComponentsVisual regression test
card/state-selectorsShouldComponentHover and focus are styled from the link, so the card only looks interactive where pressing it follows the linkOpen ComponentsReview
card/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
card/forced-colorsMustComponentThe border and the focus ring stay visible in forced colours modeOpen ComponentsEmulation
card/more-contrastShouldComponentThe border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
card/reduced-motionMustUsageNothing moves on hover or focus when prefers-reduced-motion: reduce is setWCAG 2.3.3 (AAA)Emulation
card/reflowMustUsageCards in a grid get narrower rather than overflow, so the page never scrolls sideways at 320px wideWCAG 1.4.10 (AA)Emulation
card/quiet-actionsShouldUsageThere are at most two actions, neither of them a solid ButtonOpen ComponentsReview

UX rules

RuleLevelScopeRequirementBasisCheck
card/semantic-elementMustComponentIt's an <article> by default, or a <section>, <div> or <li> with as, and never a link or a buttonOpen Components, beyond WCAG 1.3.1 (A)Unit test
card/list-of-cardsShouldUsageA set of cards is a list, with each card an <li>WCAG 1.3.1 (A)Review
card/headingMustBothEvery card has a heading that says what it's about, at the level that fits the page's outlineWCAG 1.3.1 (A), 2.4.6 (AA)Unit test, review
card/heading-firstShouldComponentThe heading comes first in the DOM, and the media and the eyebrow are shown above it with CSS aloneOpen ComponentsUnit test
card/stretched-linkMustComponentA 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 cardOpen Components, beyond WCAG 2.1.1, 4.1.2 (A)Unit test
card/link-nameMustComponentThe link is named by the heading aloneWCAG 2.4.4 (A)Unit test
card/link-purposeShouldUsageHeadings say where the link goes, differ from card to card, and there's no separate "Read more" linkWCAG 2.4.4 (A), 2.4.9 (AAA)Review
card/actions-aboveMustComponentActions and other links in the card sit outside the stretched link and above it, so pressing one runs itOpen ComponentsUnit test, review
card/no-nestingMustBothThe link holds the heading's text alone, with nothing interactive inside itHTMLUnit test
card/keyboardMustComponentTab reaches the card's link once and then its actions, and Enter follows the linkWCAG 2.1.1, 2.4.3 (A)Unit test
card/media-altMustUsageAn image that only illustrates the heading has alt="", and one that shows more has alt text that says whatWCAG 1.1.1 (A)axe image-alt, review
card/static-mediaMustUsageThe media holds nothing people can focus, since it's shown above the heading but comes after it in the DOMWCAG 1.3.2, 2.4.3 (A)Keyboard
card/dom-orderMustUsageCards come in the same order in the DOM as on screen, without order, row-reverse or a dense gridOpen Components, beyond WCAG 1.3.2, 2.4.3 (A)Keyboard
card/focus-not-obscuredMustUsageSticky headers and footers never cover a focused card, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
card/no-truncationShouldUsageHeadings aren't cut off with an ellipsis or a line clampOpen ComponentsReview
card/announce-resultsShouldUsageCards that load after something people did, like a search, are announced with a status messageWCAG 4.1.3 (AA)Screen reader
card/mirrors-in-rtlShouldComponentIt uses logical properties, so it mirrors itself in right-to-left layoutsOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
card/shared-vocabularyMustComponentIt takes href like the Button and actions like the Input, and names its other slots after its parts: heading, eyebrow, media and footerOpen ComponentsReview
card/no-attribute-namesShouldComponentNo prop shares its name with an HTML attribute that does something else, like titleOpen ComponentsReview
card/typedMustComponentProps and slots are typed, with unions rather than string or number, so headingLevel only takes 2 to 6Open ComponentsType check
card/root-elementMustComponentThe root element is the card itself, so attributes, listeners and refs land on itOpen ComponentsUnit test
card/router-linksShouldComponentA link in the heading slot, like a router's, is stretched over the card like the one href rendersOpen ComponentsUnit test, review
card/statelessShouldComponentIt keeps no state of its ownOpen ComponentsReview

AX rules

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

Reference implementation

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>

FAQ

Card or something else?

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 productsCards, in a list
Compare many things by their details, like prices and datesTable
Pick one option, like a plan, as part of a formRadio Group, which can look like a set of cards
Be told something important, like an errorAlert
Read through a stream that loads more as they scrollA list of cards, built on the APG's Feed pattern

The Table, the Radio Group and the Alert are on our Roadmap.

Sources

Copyright © 2026