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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
api-gateway
Deployed 4 minutes ago by Ada
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.
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.
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.
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.
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
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
api-gateway
3 regions · 18ms
Every part, every measurement, and the reason it is that number.
api-gateway
Deployed 4 minutes ago
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.
- Padding20px (md)
One step larger than any internal gap, so content reads as contained rather than clipped. 14px for sm, 24px for lg.
- Radius16px · --radius-xl
The container radius. Anything rounded inside caps at about 8px, or the corners visually pinch.
- 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.
- 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.
- 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.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| padding | sm / md / lg | |
| grid gap | Between cards in a collection |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xl | Card corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e1 … e3 | — | Resting and hover elevation |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-h4 | — | Card title |
| --text-body-sm | — | Card body |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Hover lift and shadow |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Padding | Radius | Icon | Label gap | Min width | Max width | When to use |
|---|---|---|---|---|---|---|---|
| Compact | 14px | 16px | — | 8px | 200px | — | Dense grids, sidebar cards, list items. |
| Default | 20px | 16px | — | 12px | 240px | 640px | The standard. Collections, forms, panels. |
| Comfortable | 24px | 16px | — | 16px | 320px | 760px | Marketing surfaces and single-card pages. |
| Stat tile | 16px | 16px | — | — | 160px | — | Dashboard metrics. Four across at 1024px. |
| Stat tile, leading | 16px | 16px | 44px chip / 20px glyph | 14px | 220px | — | Word-valued facts. Three across at 1024px — the value runs longer than a figure. |
| Grid track | — | — | — | 12px | 15rem | — | auto-fill minmax(15rem, 1fr) — reflows with no media queries. |
api-gateway
3 regions · 18ms
Three borders deep
Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Reaches an interactive card as one stop. A static card is not focusable. |
| Enter | Activates a card rendered as a link. |
| Space | Activates 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.
| Attribute | Applied to | Notes |
|---|---|---|
| <a> or <button> | The card root | A div with onClick is not focusable, does not respond to Enter, and announces as nothing. |
| aria-labelledby | The card | Points at the card title, so the link announces as "api-gateway, link" rather than reading the whole card. |
| aria-selected / aria-pressed | Selectable cards | aria-selected inside a listbox-like grid, aria-pressed for an independent toggle. |
| <article> or <li> | The card element | A collection of cards is a list. Marking it up as one gives screen-reader users the count for free. |
| aria-busy | A loading card | On the container, not on each skeleton line. Announcing twelve grey rectangles is noise. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | 'outlined' | 'filled' | 'elevated' | 'outlined' | Outlined covers almost everything. |
| elevation | 0 | 1 | 2 | 3 | 0 | Resting shadow. Interactive cards go to e3 on hover regardless. |
| interactive | boolean | false | Adds hover lift, pointer and a focus ring. Pair with as="a" or as="button". |
| selected | boolean | false | Accent border plus a 1px ring. |
| padding | 'none' | 'sm' | 'md' | 'lg' | 'md' | none for full-bleed media; pad the inner content instead. |
| as | ElementType | 'div' | 'a' or 'button' when the card is one target. |
CardHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | ReactNode | — | Rendered as an h3. |
| description | ReactNode | — | One line under the title, 4px gap. |
| icon | ReactNode | — | Leading glyph in a 32px tinted square. |
| actions | ReactNode | — | Right-aligned. Omit on interactive cards. |
| divided | boolean | false | Adds a rule and 16px of space below. |
Stat
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| icon | ReactNode | — | Decorative glyph. Muted and top-right when stacked; inside the tinted chip when leading. Pass it at size 20. |
| progress | number | — | 0–100. Renders a ring in the chip in place of the icon, for a value that is a proportion. Leading layout only. |
| delta | number | — | Percentage change. Paired with an arrow, a sign and hidden text. |
| deltaLabel | string | — | What the delta compares against — "vs last week". Stacked layout only. |
| spark | number[] | — | Sparkline series. Decorative, aria-hidden. Stacked layout only. |
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.