Analytical Engine4
Project3
A general-purpose mechanical computer.
5Let'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.
| 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 |
Overview
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.
User Experience
How a card behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.