Card

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

When to use it

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.

The smallest useful card: a surface and one padded region

Order #4417

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>

Header, body, footer

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.
Header, body and footer, with .push doing the alignment

Invoice INV-2291

Paid

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>

Why the footers line up

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.

Unequal bodies, level footers

Starter

One project.

Team

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>

Surface variants

Four looks. Each changes the background, border and shadow and nothing else, so a variant never moves anything.

Default

Border, radius and a one-pixel shadow. The everyday surface.

Default

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>

Raised

A deeper shadow and no visible border, for something floating above the page.

Raised

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>

Flush

No border, no shadow, no background. Grouping without a box drawn around it.

Flush

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

Brand-tinted background and border. For the one card that is an offer or a callout.

Brand

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>

Media

.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 image runs to the border because the card has no padding

Edge to edge

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

One anchor, stretched over the card
<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.

Responds to its own width

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.

Resize the box, not the window

Reflows on its own width

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.

Classes

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.

Classes, generated from the stylesheet source
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

Tokens

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.

Design tokens this component reads
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

Accessibility

  • A card is not a landmark. .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.
  • The stretched link is one tab stop. With .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.
  • Focus is on the anchor, not the card. The focus ring is drawn around the link text rather than the whole card, because the anchor is what has focus. This is a known rough edge of the stretched-link pattern and Deck does not work around it; if the ring matters more than the click target, use an ordinary link and drop .stretch.
  • Text stays selectable. Unlike wrapping the card in an anchor, the stretched-link pattern leaves the body text selectable with the mouse, which is why Deck uses it.
  • Touch target. The card is the target, so it clears 44px by a wide margin. Buttons inside a .card-footer keep their own targets.
  • Nothing here is announced as a group. A grid of cards is a grid of articles, not a list. If the count matters — "3 results" — put that in a heading above; the cards will not say it.

Right to left

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.

The same markup with dir="rtl"

الفاتورة ٢٢٩١

مدفوعة

ترخيصان، بفاتورة سنوية.

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

Reduced motion

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

Printing

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.

Overriding it

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.

When not to use it

  • Not for a single block on a page. One card on an otherwise empty page is a border around the content. Use .panel, which goes edge to edge on a phone and only becomes a box once there is room, or use nothing.
  • Not for a list of rows. A stack of full-width cards with one line of text in each is a list wearing borders. Use .list with .list-row — it has the dividers, the hover state and the row semantics already, and it does not draw eleven boxes.
  • Not for tabular data. If the values line up in columns, they belong in .table. Cards break the column alignment that makes a table readable, and "responsive cards" is how a table stops being comparable.
  • Not as a modal. .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.