Order #4417
Shipped 3 March, arriving Thursday.
.card is a surface: a background, a one-pixel border, a
12px radius and a shadow. It is a flex
column that brings no padding of its own, which is the one thing
about it worth remembering — the padding lives in .card-header,
.card-body and .card-footer, so each region owns its own
spacing and an image can still run edge to edge.
Use a card when a group of content is a thing — one order, one product, one person — and the reader will scan several of them side by side. The border is doing the work of saying where one thing ends and the next begins.
If there is only ever one of them on the page, you probably want
.panel or nothing at all. A single card on a white page is a border
drawn around the whole content, which tells the reader nothing.
Shipped 3 March, arriving Thursday.
<article class="card" style="max-inline-size:22rem">
<div class="card-body">
<h3 class="card-title">Order #4417</h3>
<p class="text-muted">Shipped 3 March, arriving Thursday.</p>
</div>
</article>
<article class="card" style="max-inline-size:22rem">
<div class="card-body">
<h3 class="card-title">Order #4417</h3>
<p class="text-muted">Shipped 3 March, arriving Thursday.</p>
</div>
</article>
The three regions are the reason the card itself has no padding. Each one sets its own, and the borders between them come from the regions rather than from the card, so a card with only a body has no stray dividing lines.
.card-header and .card-footer are both
flex rows with align-items: center, so a title and a
button sit on the same line without any extra wrapper. Add .push on a
child to shove the rest to the far end.
.card-footer also sets margin-block-start: auto. In a
grid of cards of unequal length that pins every footer to the bottom, so a row of
cards has its buttons aligned even when the text above them does not match.
.card-body is a flex column with a
.75rem gap, so its children space
themselves without margins.
Two licences, billed annually.
Charged to the card ending 4242.
<article class="card" style="max-inline-size:26rem">
<div class="card-header">
<h3 class="card-title">Invoice INV-2291</h3>
<span class="badge badge-good push">Paid</span>
</div>
<div class="card-body">
<p>Two licences, billed annually.</p>
<p class="text-muted text-sm">Charged to the card ending 4242.</p>
</div>
<div class="card-footer">
<button class="btn btn-sm">Download PDF</button>
<button class="btn btn-sm btn-ghost push">Email a copy</button>
</div>
</article>
<article class="card" style="max-inline-size:26rem">
<div class="card-header">
<h3 class="card-title">Invoice INV-2291</h3>
<span class="badge badge-good push">Paid</span>
</div>
<div class="card-body">
<p>Two licences, billed annually.</p>
<p class="text-muted text-sm">Charged to the card ending 4242.</p>
</div>
<div class="card-footer">
<button class="btn btn-sm">Download PDF</button>
<button class="btn btn-sm btn-ghost push">Email a copy</button>
</div>
</article>
The two cards below hold different amounts of text. The footer is pinned to the
bottom of each by margin-block-start: auto rather than by a fixed
height, so nothing has to be measured and nothing breaks when the text changes.
One project.
Unlimited projects, shared billing, audit log, and priority support during business hours.
<div class="grid grid-tight">
<article class="card">
<div class="card-body">
<h3 class="card-title">Starter</h3>
<p class="text-muted">One project.</p>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary">Choose</button></div>
</article>
<article class="card">
<div class="card-body">
<h3 class="card-title">Team</h3>
<p class="text-muted">Unlimited projects, shared billing, audit log, and priority support during business hours.</p>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary">Choose</button></div>
</article>
</div>
<div class="grid grid-tight">
<article class="card">
<div class="card-body">
<h3 class="card-title">Starter</h3>
<p class="text-muted">One project.</p>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary">Choose</button></div>
</article>
<article class="card">
<div class="card-body">
<h3 class="card-title">Team</h3>
<p class="text-muted">Unlimited projects, shared billing, audit log, and priority support during business hours.</p>
</div>
<div class="card-footer"><button class="btn btn-sm btn-primary">Choose</button></div>
</article>
</div>
Four looks. Each changes the background, border and shadow and nothing else, so a variant never moves anything.
Border, radius and a one-pixel shadow. The everyday surface.
Same markup, different surface.
<article class="card" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Default</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
<article class="card" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Default</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
A deeper shadow and no visible border, for something floating above the page.
Same markup, different surface.
<article class="card card-raised" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Raised</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
<article class="card card-raised" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Raised</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
No border, no shadow, no background. Grouping without a box drawn around it.
Same markup, different surface.
<article class="card card-flush" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Flush</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
<article class="card card-flush" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Flush</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
Brand-tinted background and border. For the one card that is an offer or a callout.
Same markup, different surface.
<article class="card card-brand" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Brand</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
<article class="card card-brand" style="max-inline-size:20rem">
<div class="card-body">
<h3 class="card-title">Brand</h3>
<p class="text-muted">Same markup, different surface.</p>
</div>
</article>
.card sets overflow: clip, so an image inside it is
trimmed to the card's radius without any radius of its own.
.card-media removes the figure's default margin and makes the image
fill the width; the card's own overflow does the rounding.
Put .card-media before .card-body, not inside it. Inside,
it inherits the body's padding and the image no longer reaches the edges.
The card clips the corners, not the image.
<article class="card" style="max-inline-size:20rem">
<figure class="card-media">
<img class="ratio-16x9" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
</figure>
<div class="card-body">
<h3 class="card-title">Edge to edge</h3>
<p class="text-muted">The card clips the corners, not the image.</p>
</div>
</article>
<article class="card" style="max-inline-size:20rem">
<figure class="card-media">
<img class="ratio-16x9" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
</figure>
<div class="card-body">
<h3 class="card-title">Edge to edge</h3>
<p class="text-muted">The card clips the corners, not the image.</p>
</div>
</article>
.card-link makes the card react to hover, and a
.stretch anchor inside it grows an invisible
::after over the entire card. The result has one accessible name, one
tab stop, and text that is still selectable — which is what separates it from
wrapping the whole card in an <a>.
The heading is the link. The whole card is the target.
<article class="card card-link" style="max-inline-size:22rem">
<div class="card-body">
<h3 class="card-title"><a class="stretch" href="#link">Quarterly report</a></h3>
<p class="text-muted">The heading is the link. The whole card is the target.</p>
</div>
</article>
<article class="card card-link" style="max-inline-size:22rem">
<div class="card-body">
<h3 class="card-title"><a class="stretch" href="#link">Quarterly report</a></h3>
<p class="text-muted">The heading is the link. The whole card is the target.</p>
</div>
</article>
A second link inside a stretched card needs position: relative to stay
clickable, because the stretched ::after sits above everything that is
not positioned. Reach for .relative.
This is the part of the card worth learning. .card.cq turns the card
into a query container, and .card-flex then reflows on the card's
own width rather than the viewport's: a column below
30rem, a horizontal media object above it. The same card works in a
wide main column and in a narrow sidebar with no breakpoint and no extra class.
Drag the resizer on the example below. Nothing about the viewport changes.
Wider than 30rem and the media moves beside the text.
<div class="cq" style="resize:horizontal;overflow:auto;min-inline-size:15rem;max-inline-size:100%;inline-size:22rem;padding-inline-end:var(--space-3)">
<article class="card card-flex">
<figure class="card-media">
<img alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22480%22 height=%22270%22%3E%3Crect width=%22480%22 height=%22270%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
</figure>
<div class="card-body">
<h3 class="card-title">Reflows on its own width</h3>
<p class="text-muted">Wider than 30rem and the media moves beside the text.</p>
</div>
</article>
</div>
<div class="cq" style="resize:horizontal;overflow:auto;min-inline-size:15rem;max-inline-size:100%;inline-size:22rem;padding-inline-end:var(--space-3)">
<article class="card card-flex">
<figure class="card-media">
<img alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22480%22 height=%22270%22%3E%3Crect width=%22480%22 height=%22270%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
</figure>
<div class="card-body">
<h3 class="card-title">Reflows on its own width</h3>
<p class="text-muted">Wider than 30rem and the media moves beside the text.</p>
</div>
</article>
</div>
.card-flex needs a query container somewhere above it. .cq
on the card's parent is the usual answer; .card.cq on the card itself
works when the card is the one being sized.
Generated from the stylesheet source by tools/docs/extract.mjs. If a
class is added or removed in src/ and this table is not updated,
tools/docs/verify.mjs fails the build.
| Class | Layer | What it does | Source |
|---|---|---|---|
.card |
deck.components |
A surface with a border, radius and shadow. It brings no padding of its own — put .card-body inside so the header, body and footer can each own their spacing. | 07-components.css:11 |
.card-body |
deck.components |
The padded region of a card. A column with a gap, so children stack without margins. | 07-components.css:37 |
.card-brand |
deck.components |
Brand-tinted fill and border, for the one card on a page that is an offer rather than a record. | 07-components.css:60 |
.card-flex |
deck.layout |
A card that turns into a horizontal media object once it has the room | 18-container.css:44 |
.card-flush |
deck.components |
A card with no border, shadow or background. Grouping without a box drawn around it. | 07-components.css:54 |
.card-footer |
deck.components |
The top region of a card. A flex row, so a title and a control sit on one line; the divider below it belongs to the header, so a card with no header has no stray line. | 07-components.css:27 |
.card-header |
deck.components |
The top region of a card. A flex row, so a title and a control sit on one line; the divider below it belongs to the header, so a card with no header has no stray line. | 07-components.css:27 |
.card-link |
deck.components |
Whole card is the link, but nested links still work | 07-components.css:48 |
.card-media |
deck.components |
An image region that reaches the card's edges. The card's own overflow: clip does the rounding, so the image needs no radius. Put it beside .card-body, never inside it. | 07-components.css:44 |
.card-raised |
deck.components |
A deeper shadow and no visible border, for a surface that should read as floating above the page. | 07-components.css:57 |
.card-title |
deck.components |
The heading inside a card. Sized independently of h1-h6 so the same card works at any heading level. | 07-components.css:40 |
The card reads global tokens directly rather than defining local
--card-* properties, so restyling it means overriding the rule rather
than setting a variable. That is a real difference from .btn — see
Overriding it.
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--r-md |
12px |
|
--shadow-1 |
0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) |
|
--shadow-3 |
0 4px 8px oklch(20% .03 var(--shadow-color) / .06), 0 12px 28px oklch(20% .03 var(--shadow-color) / .10) |
|
--brand-200 |
oklch(88.6% calc(var(--chroma-brand) * .48) var(--hue-brand)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--text-md |
1.0625rem |
|
--space-5 |
1.25rem |
.card is a plain box with
no implicit role. Use <article> when the contents stand alone,
or a <section> with a heading. A bare <div>
is fine when the card is decoration around content that is already structured.
.card-link and a .stretch anchor, the keyboard reaches
the card once and the accessible name is the anchor's text — so that text has to
make sense on its own. "Read more" in a stretched card gives a screen-reader user
a list of identical links.
.stretch.
.card-footer keep their own targets.
The card is written in logical properties throughout:
border-block-end on the header, margin-block-start on the
footer, padding-inline everywhere. Nothing about it is side-specific,
so dir="rtl" needs no extra rules. The one directional thing is the
hover arrow — src/19-logical.css flips
.icon-follow so it steps toward the text end rather than always to the
right.
ترخيصان، بفاتورة سنوية.
<article dir="rtl" class="card" style="max-inline-size:22rem">
<div class="card-header">
<h3 class="card-title">الفاتورة ٢٢٩١</h3>
<span class="badge badge-good push">مدفوعة</span>
</div>
<div class="card-body"><p>ترخيصان، بفاتورة سنوية.</p></div>
</article>
<article dir="rtl" class="card" style="max-inline-size:22rem">
<div class="card-header">
<h3 class="card-title">الفاتورة ٢٢٩١</h3>
<span class="badge badge-good push">مدفوعة</span>
</div>
<div class="card-body"><p>ترخيصان، بفاتورة سنوية.</p></div>
</article>
.card-link transitions box-shadow and
border-color. Under prefers-reduced-motion: reduce the
global reset collapses both to .01ms, so the hover state still happens
— it just arrives immediately.
The stepping arrow in src/16-motion.css is declared inside
@media (prefers-reduced-motion: no-preference), so it is never
declared at all for a reader who has asked for less motion, rather than being
declared and then overridden.
src/99-print.css gives cards a plain #bbb hairline, drops
the radius and the shadow, forces a white background, and sets
break-inside: avoid so a card does not split across two sheets.
.card-footer keeps a grey fill with
print-color-adjust: exact, so the region reads as a footer on paper
rather than as more body text.
Deck reserves four empty app.* layers. A rule in
app.components beats every Deck rule regardless of specificity, so a
single class selector is enough and !important is never needed.
@layer app.components {
.card {
border-radius: var(--r-lg);
box-shadow: none;
border-color: var(--brand-200);
}
}
Unlike .btn, the card has no local custom properties to aim at. That is
a deliberate difference — a card is a surface, and the surface tokens
(--surface, --line, --r-md) are global on
purpose, so changing them restyles panels, lists and tables to match instead of
leaving the card looking like the odd one out.
.panel, which goes edge to
edge on a phone and only becomes a box once there is room, or use nothing.
.list with
.list-row — it has the dividers, the hover state and the row
semantics already, and it does not draw eleven boxes.
.table. Cards break the column alignment that makes a
table readable, and "responsive cards" is how a table stops being comparable.
.modal carries the backdrop, the
focus trap, the escape handling and the scroll lock. A .card with
position: fixed has none of them.
.card-flush versus no card at all.
.card-flush removes the border, the shadow and the background, which
leaves a flex column with overflow: clip. If you are not going to use
the header/body/footer regions, that is .stack with extra steps.