Skip to content

Card

A container for content that belongs together and can be acted on as a unit. If neither is true, you want a section with a heading.

Also called Tile, Panel, Media Card — in this system all of them are Card.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

api-gateway

Deployed 4 minutes ago by Ada

Live

Three regions, twelve instances. Median response 18ms over the last hour.

A collection

Interactive cards in an auto-filling grid. The whole card is one link, hover lifts by one elevation step and 1px, and nothing inside is separately clickable.

Stat tiles

The dashboard atom. The number is the largest thing, the label is the smallest, and the delta is paired with an arrow and a sign so it survives greyscale.

Requests
1.24M+12.4% increase
vs last week
Error rate
0.41%-8.1% decrease
vs last week
p95 latency
184ms+3.2% increase
vs last week
Uptime
99.98%+0.01% increase
30-day rolling

Stat tiles — leading chip

layout="leading" moves the icon into a 44px tinted chip at the front and drops the value from 32px to 16px. Use it when the value is a word rather than a figure — a state, a phase, a verdict. The chip carries the tone so the value itself can stay plain text, and a ring can replace the icon where the fact is a proportion.

Status
Complete
Stage
Published
Lessons
12 / 12
Progress
100%
Total audio
25:42
Source validation
Some content flagged

Panels

A card with a divided header and footer. Use when the card contains a list or a table and the header needs to hold controls.

Recent deployments

Last 24 hours

api-gatewayALLive
billing-workerALLive
edge-cacheALLive

Updated 4 minutes ago

Variants

Outlined is the default and covers almost everything. Filled recedes into the page; elevated lifts out of it. Pick one per surface and stay with it.

outlined

The default. A hairline and a surface.

filled

Recedes. For secondary groupings.

elevated

Lifts. For draggable or floating items.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

api-gateway

3 regions · 18ms

Default

api-gateway

3 regions · 18ms

Hover−1px, e3

api-gateway

3 regions · 18ms

Pressed

api-gateway

3 regions · 18ms

Focus

api-gateway

3 regions · 18ms

Selected

api-gateway

3 regions · 18ms

Filled

api-gateway

3 regions · 18ms

Elevated

api-gateway

3 regions · 18ms

Disabled
Loading

api-gateway

3 regions · 18ms

Dragginge4, 2° tilt
Drop target

api-gateway

3 regions · 18ms

Error

Anatomy

Every part, every measurement, and the reason it is that number.

api-gateway

Deployed 4 minutes ago

Live

Three regions, twelve instances.

Header with an icon slot and an action slot, body, and a divided footer. The divider gets more space above than below.

  1. Padding20px (md)

    One step larger than any internal gap, so content reads as contained rather than clipped. 14px for sm, 24px for lg.

  2. Radius16px · --radius-xl

    The container radius. Anything rounded inside caps at about 8px, or the corners visually pinch.

  3. Border1px --ds-border-subtle

    The hairline does most of the edge definition, especially in light mode where the surface and the page are both white.

  4. Header gap12px icon, 4px title/desc

    Title and description are 4px apart because they are one unit; the gap to the body is 12px, three times larger.

  5. Footer divider20px above, 16px below

    Asymmetric on purpose. The eye reads a rule as belonging to the content beneath it, so it needs more air above.

  6. Hover lift−1px, e0 → e3

    One pixel and one elevation step. Any more and the card appears to jump, which makes it harder to click.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface—Outlined and elevated background
--ds-surface-inset—Filled background
--ds-surface-raised—Elevated background
--ds-border-subtle—Card edge, header and footer dividers
--ds-accent—Selected border
--ds-layer-hover—Hover wash on filled cards

Spacing

TokenValueUsed for
paddingsm / md / lg
grid gapBetween cards in a collection

Radius

TokenValueUsed for
--radius-xlCard corners

Shadow

TokenValueUsed for
--shadow-e1 … e3—Resting and hover elevation

Typography

TokenValueUsed for
--text-h4—Card title
--text-body-sm—Card body

Motion

TokenValueUsed for
durationHover lift and shadow

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizePaddingRadiusIconLabel gapMin widthMax widthWhen to use
Compact14px16px—8px200px—Dense grids, sidebar cards, list items.
Default20px16px—12px240px640pxThe standard. Collections, forms, panels.
Comfortable24px16px—16px320px760pxMarketing surfaces and single-card pages.
Stat tile16px16px——160px—Dashboard metrics. Four across at 1024px.
Stat tile, leading16px16px44px chip / 20px glyph14px220px—Word-valued facts. Three across at 1024px — the value runs longer than a figure.
Grid track———12px15rem—auto-fill minmax(15rem, 1fr) — reflows with no media queries.

Do

Make an interactive card a single targetOne <a> or <button> wrapping the whole card gives one focus stop, one hover state and one obvious action. Three nested links inside a clickable card is a keyboard trap and an ambiguous click.
Let the grid reflow itselfauto-fill with minmax means the card count per row adapts to any container — including a resized panel, where media queries know nothing.
Keep the padding larger than the internal gapsIf a card has 16px padding and 16px gaps, the content looks like it is trying to escape. One step of difference reads as deliberate containment.

api-gateway

3 regions · 18ms

Match the skeleton to the cardA loading placeholder that is the same size and shape as the real card means no layout shift when data lands, and the user keeps their place.

Don't

Three borders deep

Do not nest cardsTwo borders and two shadows one inside the other is visual noise with no added meaning. The inner grouping should be spacing, or at most a filled block with no border.
Do not put multiple links inside a clickable cardNested interactive elements inside a link are invalid HTML, produce unpredictable activation, and leave keyboard users unable to reach the inner controls reliably.
api-gatewayLive · 3 regions · 18ms
billing-workerLive · 3 regions · 18ms
…this wants to be a table
Do not use cards for comparable rowsSix cards each with the same four fields is a table drawn badly. A table aligns the fields into columns, which is what makes them comparable.
Do not elevate every card in a gridElevation is relative. Twelve floating cards read as visual noise and none of them stands out — which was the reason for the shadow in the first place.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.4.4Link Purpose (In Context)A2.4.7Focus VisibleAA4.1.2Name, Role, ValueA

Contrast

  • The card border must reach 3:1 against the page if it is the only thing separating the card from the background.
  • In dark mode the surface itself carries the separation, so the hairline can be softer. In light mode both surfaces are white and the hairline is doing all the work.
  • A selected card uses a border plus a 1px ring so the state survives greyscale and high-contrast mode.

Keyboard

TabReaches an interactive card as one stop. A static card is not focusable.
EnterActivates a card rendered as a link.
SpaceActivates a card rendered as a button. Links do not respond to Space.
↑ ↓ ← →Optional roving focus in a card grid, so Tab does not stop on all forty.

Screen readers

  • A card collection should be a real list, so it announces as "list, 12 items". A pile of divs announces as nothing.
  • The accessible name of an interactive card should be the title, not the entire contents. Without aria-labelledby, the whole card is read as the link text.
  • Do not hide the primary action behind hover. A "View" button that only appears on hover is unreachable by keyboard and invisible on touch.

Focus & touch

  • A 2px ring at 2px offset around the whole card. Because the card already has a border and a radius, the offset matters — a flush ring reads as a thicker border rather than as focus.
  • A whole-card target is comfortably above 44px, which is one of the reasons cards work well on mobile. Keep any secondary action inside a card at least 44px and separated by 8px from the card edge.
AttributeApplied toNotes
<a> or <button>The card rootA div with onClick is not focusable, does not respond to Enter, and announces as nothing.
aria-labelledbyThe cardPoints at the card title, so the link announces as "api-gateway, link" rather than reading the whole card.
aria-selected / aria-pressedSelectable cardsaria-selected inside a listbox-like grid, aria-pressed for an independent toggle.
<article> or <li>The card elementA collection of cards is a list. Marking it up as one gives screen-reader users the count for free.
aria-busyA loading cardOn the container, not on each skeleton line. Announcing twelve grey rectangles is noise.

Code

Example usage

tsx
1import { Card, CardHeader, CardFooter, Panel, Stat } from '@/ui/Surface'23// Static card4<Card>5  <CardHeader title="api-gateway" description="Deployed 4 minutes ago" />6  <p className="mt-3 text-body-sm text-fg-muted">Three regions, twelve instances.</p>7  <CardFooter>8    <Button size="sm" variant="text">Logs</Button>9    <Button size="sm" variant="outlined">Rollback</Button>10  </CardFooter>11</Card>1213// Interactive: ONE target for the whole card14<Card as="a" href={'/projects/' + id} interactive aria-labelledby={titleId}>15  <h3 id={titleId}>{project.name}</h3>16  <p>{project.summary}</p>17</Card>1819// A collection is a real list20<ul className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))]">21  {projects.map((p) => (22    <li key={p.id}>23      <Card as="a" href={p.href} interactive>…</Card>24    </li>25  ))}26</ul>2728// Panel: a card whose header holds controls29<Panel title="Recent deployments" actions={<Button size="xs">View all</Button>} bodyClassName="p-0">30  <DeploymentList />31</Panel>3233// Stat tile: a number you are tracking34<Stat label="Requests" value="1.24M" delta={12.4} deltaLabel="vs last week" spark={series} />3536// Stat tile: a fact you read once — icon in a tinted chip, value as a word37<Stat layout="leading" tone="success" icon={<CheckCircle2 size={20} />}38      label="Status" value="Complete" />3940// …or a ring in place of the icon, when the fact is a proportion41<Stat layout="leading" tone="info" progress={72} label="Progress" value="72%" />

Framework-free HTML

html
<article class="ds-card">
  <header class="ds-card__header">
    <h3 class="ds-card__title" id="card-1-title">api-gateway</h3>
    <p class="ds-card__desc">Deployed 4 minutes ago</p>
  </header>

  <div class="ds-card__body">Three regions, twelve instances.</div>

  <footer class="ds-card__footer">
    <button class="ds-btn ds-btn--text ds-btn--sm">Logs</button>
    <button class="ds-btn ds-btn--outlined ds-btn--sm">Rollback</button>
  </footer>
</article>

<!-- Interactive: the anchor IS the card, and it names itself -->
<a class="ds-card ds-card--interactive" href="/projects/1" aria-labelledby="card-1-title">
  <h3 id="card-1-title">api-gateway</h3>
  <p>Three regions, twelve instances.</p>
</a>

CSS

css
.ds-card {
  position: relative;
  padding: 20px;                          /* one step above any inner gap */
  background: var(--ds-surface);
  border: 1px solid var(--ds-border-subtle);
  border-radius: var(--radius-xl);        /* 16 — inner radii cap at ~8 */
  transition:
    transform  180ms var(--ease-standard),
    box-shadow 180ms var(--ease-standard),
    border-color 180ms var(--ease-standard);
}

.ds-card--interactive {
  cursor: pointer;
  display: block;
  color: inherit;
  text-decoration: none;
}
.ds-card--interactive:hover {
  transform: translateY(-1px);            /* one pixel, not four */
  border-color: var(--ds-border);
  box-shadow: var(--shadow-e3);
}
.ds-card--interactive:active {
  transform: none;
  box-shadow: var(--shadow-e1);
}
.ds-card--interactive:focus-visible {
  outline: 2px solid var(--ds-focus-ring);
  outline-offset: 2px;
}

/* Selected: border plus a ring, so the edge reads as 2px without reflow */
.ds-card[data-selected] {
  border-color: var(--ds-accent);
  box-shadow: 0 0 0 1px var(--ds-accent);
}

/* Footer divider gets more air above than below */
.ds-card__footer {
  margin-block-start: 20px;
  padding-block-start: 16px;
  border-block-start: 1px solid var(--ds-border-subtle);
}

/* Clip anything that reaches the rounded edge */
.ds-card--media { overflow: hidden; padding: 0; }

@media (prefers-reduced-motion: reduce) {
  .ds-card--interactive:hover { transform: none; }
}

Component API

Card

PropTypeDefaultDescription
variant'outlined' | 'filled' | 'elevated''outlined'Outlined covers almost everything.
elevation0 | 1 | 2 | 30Resting shadow. Interactive cards go to e3 on hover regardless.
interactivebooleanfalseAdds hover lift, pointer and a focus ring. Pair with as="a" or as="button".
selectedbooleanfalseAccent border plus a 1px ring.
padding'none' | 'sm' | 'md' | 'lg''md'none for full-bleed media; pad the inner content instead.
asElementType'div''a' or 'button' when the card is one target.

CardHeader

PropTypeDefaultDescription
title*ReactNode—Rendered as an h3.
descriptionReactNode—One line under the title, 4px gap.
iconReactNode—Leading glyph in a 32px tinted square.
actionsReactNode—Right-aligned. Omit on interactive cards.
dividedbooleanfalseAdds a rule and 16px of space below.

Stat

PropTypeDefaultDescription
label*string—What the value measures. Overline case, always the smallest text in the tile.
value*ReactNode—The figure, at h2 with tabular numerals — or a word at h4 under layout="leading".
layout'stacked' | 'leading''stacked'stacked leads with the number; leading puts the icon in a 44px chip at the front and shrinks the value to 16px.
tone'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info''neutral'Colours the leading chip. Semantic, never decorative. The stacked layout has no chip and ignores it.
iconReactNode—Decorative glyph. Muted and top-right when stacked; inside the tinted chip when leading. Pass it at size 20.
progressnumber—0–100. Renders a ring in the chip in place of the icon, for a value that is a proportion. Leading layout only.
deltanumber—Percentage change. Paired with an arrow, a sign and hidden text.
deltaLabelstring—What the delta compares against — "vs last week". Stacked layout only.
sparknumber[]—Sparkline series. Decorative, aria-hidden. Stacked layout only.

Notes

Professional tips

  • Ask "could this be a link?" before adding a card. If the answer is no, a heading and whitespace will read better and weigh less.
  • For a card with a full-bleed image, set padding="none" and pad the text block instead — and put overflow-hidden on the card so the image does not square off the corners.
  • In a grid, give every card the same height with align-items: stretch and push the footer down with margin-top: auto. Ragged card bottoms make a grid look broken.
  • A card that contains a form should not also be clickable. Pick one: container or target.

Performance

  • content-visibility: auto on offscreen cards skips their layout and paint entirely. On a page with two hundred cards this is often the single biggest win.
  • Virtualise past about a hundred cards. Cards are heavier than table rows because each one is a small layout of its own.
  • Do not animate box-shadow on a grid of cards during scroll. Shadow is painted on the CPU; use a pre-composited layer or animate opacity between two stacked shadows.
  • Lazy-load card images with loading="lazy" and an explicit width and height, or the grid reflows as each one arrives.

Common mistakes

  • A div with onClick as the card root — not focusable, no Enter, announces as nothing.
  • Nested interactive elements inside a card that is itself a link. Invalid HTML and unpredictable behaviour.
  • Forgetting overflow-hidden on a card with an image, so the image squares off the rounded corners.
  • Using a card to section a page, then wondering why the page looks busy. Sections want headings, not borders.
  • Hover-only actions inside a card. Invisible on touch, unreachable by keyboard.

Real-world recommendations

  • Cards are the right default for mobile and the wrong default for dense desktop data. Many products need both: a card list under md and a table above it.
  • In a dashboard, keep stat tiles to one row of four. A second row of metrics is almost never read, and it pushes the actual content below the fold.
  • When a card grid regularly holds more than about fifty items, users are searching rather than browsing. Add search and filtering before adding pagination.
  • Give every card exactly one primary action and hide the rest behind an overflow menu. Three visible buttons per card multiplied by twelve cards is thirty-six competing targets.