Card

User Interface (UI)

How a card looks, from its parts and what goes in each of them to how cards sit together, its states and its tokens.

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.

UI Checklist

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

FAQ

Copyright © 2026