Card
Surfaces
Open in Storybook: Card (opens in a new tab)
Overview
A card is an article. Interactivity comes from one real link or button whose hit area CSS widens: the box is never the control. A link goes somewhere, a button does something.
The rule. An article named by its heading. One real a or button does the work, and ::after with inset: 0 stretches its hit area over the card. Every other control is a sibling lifted above the overlay, never nested. Stretch only when the card has one clear destination.
Anatomy
- Card
article aria-labelledbyNamed by its heading. Never the control.
- Image
img alt=""Decoration: the heading says it.
- Link
h3>aA real link in the heading. Its text is the card’s name, and its
::afteris the card’s hit area. - Controls
buttonsiblingsBeside the link, never inside it, and above the overlay.
Usage
Use cases
Ten card types, each the real component and each working.
Category tile grid
Simple
ul role="list">li>articlelist-style: nonedrops the list in Safari: the role keeps it.Hover-image
Simple
Kyoto, day and night
The picture changes on hover and on focus.
ain the heading, stretched · a second pictureNothing the swap shows is needed to use the card.
Disclosure
Simple
What’s in the box?
Machine, 12 capsules, welcome guide.
A picture · native
details·summaryover the header rowThe header row is the control; neither the picture nor what it opens is.
Product
Complex
Ristretto
Intense and short.
€ 0,45
Stretched link + Add to cart + Save
Add to cart and Save are siblings above the overlay.
Article + bookmark
Complex
Five steps, ten minutes.
Stretched link + corner Save + footer link
Save is a sibling above the overlay, never inside the link.
Pricing
Complex, no stretch
Pro plan
€ 9 / month. Unlimited recipes.
Two equal buttons with distinct names
No single destination, so no overlay.
Variant product
Complex, no stretch
Vertuo Pop
€ 99
1 to 9
A link in the heading, not stretched · swatches in a
fieldset· a stepper · Add to cartEach needs its own clicks, so no overlay. − and + are pointer-only.
Booking
Complex
Barista class
Saturdays, Milan. 3 hours.
Stretched link + a button that opens a drawer
The card owns the trigger and the focus return.
Props
| Prop | Does | Default |
|---|---|---|
title | The name: the heading, and the words of the link or button | Required |
level | The heading level, from the outline of the page | 3 |
href | A link in the heading, stretched. For one clear destination | — |
stretch | false keeps the link of href plain: its words are the target and there is no overlay. For a card whose controls each need their own clicks | true |
onAction | A button in the heading, stretched. It does something here | — |
disclosure | A native details: the heading is its summary | false |
media · swap | The picture, and one that comes on hover and on focus. alt="" | — |
corner | The end of the heading row: the save button | — |
controls | Siblings of the link, above the overlay | — |
Example
import { Card, CardGrid, CardSave } from '@/components/Card'
<CardGrid>
<Card
title="Ristretto"
href="/ristretto"
controls={<CardSave name="Save Ristretto to favorites" />}
>
<p>Intense and short.</p>
</Card>
</CardGrid>Specs
Stretched link
Nothing is positioned between the link and the article: a heading with position: relative shrinks the overlay to its own box and the rest of the card goes dead.
.card { position: relative; } /* 1. the overlay's containing block */
.card__link { position: static; } /* 2. a real link in the heading, static on focus too */
.card__link::after { /* 3. the card is the link's hit area */
content: ''; position: absolute; inset: 0;
}
.card__control { /* every other control sits above it */
position: relative; z-index: 1;
min-inline-size: 2.75rem; min-block-size: 2.75rem;
}
.card:has(.card__link:focus-visible) { /* 4. the whole card is ringed */
outline: 2px solid currentColor; outline-offset: 4px;
}
.grid {
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}Markup
<!-- static -->
<article aria-labelledby="kyoto-h">
<img src="kyoto.jpg" alt="">
<h2 id="kyoto-h">Kyoto, Japan</h2>
<p>Temples, tea houses and the Philosopher’s Path.</p>
</article>
<!-- complex -->
<article class="card" aria-labelledby="product-1">
<h3 id="product-1"><a class="card__link" href="/p/1">Product 1</a></h3>
<p>Description of product 1</p>
<button class="card__control">Add product 1 to cart</button>
<button class="card__control" aria-pressed="false">
<svg aria-hidden="true">…</svg>
<span class="visually-hidden">Save product 1 to favorites</span>
</button>
</article>States
| State | Look |
|---|---|
| Hover | A shadow and a lift on the article, and the link underlined. Only where hovering exists |
| Focus | A 2px ring on the whole card, from :has(:focus-visible). The stretched control has none of its own; every other control keeps the site’s ring. Hover effects fire on focus too |
| Forced colours | The ring is currentColor and survives; the border keeps the card edge |
| Reduced motion | No transition on the lift or the swap |
Tokens
Component tokens
| Part | Property | Token | Value |
|---|---|---|---|
| Card | Border · corner | line · radius | #d7d7dc · 8px |
| Card | Fill · text | white · ink | #ffffff · #16161a |
| Description | Text colour | ink-soft | #4b4b55 |
| Picture | Fill | wash | #edebf4 |
| Ring | Outline | — | 2px currentColor, 4px out |
| Save, on | Fill | lilac | #5e548e |
Guidelines
When to stretch
Stretch a card that has one clear destination. Do not when it has two equal calls to action or holds controls that need their own clicks (swatches, a stepper, inline links, a menu): the overlay steals every click that misses one. Such a card can still say where it goes: a link in the heading with stretch={false} is a plain link, and its words are its target.
Names
| Case | Don’t | Do |
|---|---|---|
| Link text | <a>Click here</a> | <a>Kyoto, Japan</a> |
| Visible text | <a aria-label="Open destination">Kyoto, Japan</a> | No aria-label: the visible text is the name (2.5.3) |
| Repeated calls | <a>Start trial</a> three times | Start trial<span class="visually-hidden">: Pro plan</span> |
| Icon-only button | <button aria-label="♥"> | Hidden text, “Save Kyoto to favorites”, and an svg with aria-hidden |
| Toggle | The label flips to “Saved” and aria-pressed="true" | A stable name and aria-pressed: the state is said once |
Result messages
What a card says after an action, “added” or “copied”, is said once from the button with ariaNotify(). No live region, and nothing put in the page: where ariaNotify is missing nothing is said.
Controls by scenario and context
Each row is a card type from the use cases. Each column shows the controls that take a tab stop in that context. Chips stand for the stretched link or a native control; the buttons are the Modal Dialog button and the card’s Save button themselves. Zoom uses the reflow of the card.
- Solid: required in that context, never dropped
- Modal · ListOutside the card: the dialog it opens, or the list it sits in
Zoom 400% · 320 CSS px. At 200% text the grid drops to one column.
Link, promo, teaser
Simple · stretched
- Desktop · 640 px and upGrid, 3–4 per row
One stop: the link in the heading
Whole-card ring via :has(:focus-visible); hover lifts and underlines.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Two cards per row.
- Zoom 400% · 320 CSS pxReflow
Same
One per row; text wraps, no fixed heights.
Category tile grid
ul role="list" > li > article
- Desktop · 640 px and upGrid, 3–4 per row
One stop per tile
List
role="list" keeps list semantics where list-style: none removes them.
- Smaller viewport · 400–639 pxGrid, 2 per row
Two per row
repeat(auto-fit, minmax(min(100%, 14rem), 1fr)).
- Zoom 400% · 320 CSS pxReflow
One per row
Same grid, one column.
Button only
Opens a modal
- Desktop · 640 px and upGrid, 3–4 per row
One button
Modal
Not a link: it doesn’t navigate. Focus returns to it when the dialog closes.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Same
The button fills the card.
Disclosure
details / summary
- Desktop · 640 px and upGrid, 3–4 per row
One stop: summary
State exposed natively. Ring the card on summary:focus-visible.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Same
Content wraps below the summary.
Product
Complex · stretched
- Desktop · 640 px and upGrid, 3–4 per row
Link · Add to cart · Save
Three stops, link first. Added to cart is said once, from the button, with ariaNotify.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Controls wrap under the link
Controls stay 44 × 44 and above the overlay.
Article + bookmark
Complex · corner save
- Desktop · 640 px and upGrid, 3–4 per row
Link · Save (corner) · footer link
Save and the footer link are lifted above the overlay.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Same
Save stays in the corner; the heading wraps around it.
Pricing
Complex · no stretch
- Desktop · 640 px and upGrid, 3–4 per row
Two equal CTAs
Distinct names: “Start trial: Pro plan”. No overlay: no single destination.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Stacked
Buttons wrap, one per row.
Variant product
Complex · no stretch
- Desktop · 640 px and upGrid, 3–4 per row
Link · swatches · stepper · Add to cart
Each needs its own clicks: no overlay, and the link is not stretched. −/+ are pointer-only (tabindex="-1").
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Stacked
One control per row.
Booking
Complex · opens a drawer
- Desktop · 640 px and upGrid, 3–4 per row
Link · Book a seat
Modal
The button opens a stepper in a modal drawer; focus returns to it.
- Smaller viewport · 400–639 pxGrid, 2 per row
Same
Same.
- Zoom 400% · 320 CSS pxReflow
Same
Same.
Accessibility
Keyboard and focus
Tab follows the DOM. The stretched link adds no extra stop.
| Card | Tab stops |
|---|---|
| Static | None |
| Simple, link | One: the link. Enter follows it |
| Simple, button or details | One. Enter or Space |
| Complex | The link first, then each control in DOM order |
| Grid of cards | Card after card |
Roles and properties
| Part | Exposed |
|---|---|
| Card | article, named by its heading through aria-labelledby. The id is made once per card |
| Disclosure | Its state, natively, from details |
| Save | button, “pressed”, with a name that never changes |
| Image | None: alt="" |
| Grid | list, a listitem for each card |
Support
| Browser | Checked |
|---|---|
| Chromium | Yes |
| WebKit (Safari) | Yes |
| Firefox | No |
WCAG criteria
| Criterion | Met by |
|---|---|
| 1.3.1 Info and Relationships (A) | An article named by its heading; a grid of tiles as ul role="list" |
| 1.4.4 Resize Text (AA) | rem, and no fixed heights on text |
| 1.4.10 Reflow (AA) | One column at 320 CSS px, with no sideways scroll |
| 2.1.1 Keyboard (A) | Every action is a native a, button or summary; hover effects also on focus |
| 2.3.3 Animation from Interactions (AAA) | The lift and the swap are off under reduced motion |
| 2.4.3 Focus Order (A) | Tab follows the DOM; the stretched link adds no extra stop |
| 2.4.4 Link Purpose (In Context) (A) | “Kyoto, Japan” is clear next to the heading |
| 2.4.7 Focus Visible (AA) | The card is ringed through :has(:focus-visible); every other control has its own ring |
| 2.5.3 Label in Name (A) | The accessible name contains the visible text |
| 2.5.8 Target Size (Minimum) (AA) | Controls over 24 × 24 CSS px; 44 × 44 here |
| 4.1.2 Name, Role, Value (A) | Native roles and names; details exposes its state; aria-pressed on toggles |
| 4.1.3 Status Messages (AA) | Added, copied: said once with ariaNotify() |
Related
This section is still in progress.