Design System

Design System

Card

Surfaces

Open in Storybook: Card (opens in a new tab)

On this page

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

  1. Card

    article aria-labelledby

    Named by its heading. Never the control.

  2. Image

    img alt=""

    Decoration: the heading says it.

  3. Link

    h3 > a

    A real link in the heading. Its text is the card’s name, and its ::after is the card’s hit area.

  4. Controls

    button siblings

    Beside 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 > article

    list-style: none drops the list in Safari: the role keeps it.

  • Hover-image

    Simple

    a in the heading, stretched · a second picture

    Nothing the swap shows is needed to use the card.

  • Button only

    Simple

    45 minutes with a coffee specialist.

    Book a tasting

    Saturday at 11:00, with a coffee specialist.

    One button in the heading, that opens a modal

    A button because it does not navigate. Focus returns to it.

  • Disclosure

    Simple

    What’s in the box?

    Machine, 12 capsules, welcome guide.

    A picture · native details · summary over the header row

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

  • 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

    Colour

    1 to 9

    A link in the heading, not stretched · swatches in a fieldset · a stepper · Add to cart

    Each needs its own clicks, so no overlay. − and + are pointer-only.

  • Booking

    Complex

    Barista class

    Saturdays, Milan. 3 hours.

    Book a seat

    1 to 6

    Stretched link + a button that opens a drawer

    The card owns the trigger and the focus return.

Props

Props
PropDoesDefault
titleThe name: the heading, and the words of the link or buttonRequired
levelThe heading level, from the outline of the page3
hrefA link in the heading, stretched. For one clear destination—
stretchfalse 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 clickstrue
onActionA button in the heading, stretched. It does something here—
disclosureA native details: the heading is its summaryfalse
media · swapThe picture, and one that comes on hover and on focus. alt=""—
cornerThe end of the heading row: the save button—
controlsSiblings 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

States
StateLook
HoverA shadow and a lift on the article, and the link underlined. Only where hovering exists
FocusA 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 coloursThe ring is currentColor and survives; the border keeps the card edge
Reduced motionNo transition on the lift or the swap

Tokens

Component tokens

Component tokens
PartPropertyTokenValue
CardBorder · cornerline · radius#d7d7dc · 8px
CardFill · textwhite · ink#ffffff · #16161a
DescriptionText colourink-soft#4b4b55
PictureFillwash#edebf4
RingOutline—2px currentColor, 4px out
Save, onFilllilac#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

Names
CaseDon’tDo
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 timesStart 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
ToggleThe 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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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

    Live example

    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.

Keyboard and focus
CardTab stops
StaticNone
Simple, linkOne: the link. Enter follows it
Simple, button or detailsOne. Enter or Space
ComplexThe link first, then each control in DOM order
Grid of cardsCard after card

Roles and properties

Roles and properties
PartExposed
Cardarticle, named by its heading through aria-labelledby. The id is made once per card
DisclosureIts state, natively, from details
Savebutton, “pressed”, with a name that never changes
ImageNone: alt=""
Gridlist, a listitem for each card

Support

Support
BrowserChecked
ChromiumYes
WebKit (Safari)Yes
FirefoxNo

WCAG criteria

WCAG criteria
CriterionMet 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.

References

Read the post: Learn how to build a semantic, accessible and interactive Card UI using HTML and CSS (opens in a new tab)