Project milestones
Track progress across sprints. Update statuses on a weekly cadence, push blockers to the top, and let the team see where velocity pools.
Raised content surface
Card is a raised surface that groups related content under one
visual boundary: an article preview, a stat block, a feature
card, a product row, a dashboard tile. It carries a background,
border, radius, and shadow; everything inside inherits the
card's rhythm from --card-gap.
A bare .card is enough. Drop a heading, body
copy, and any supporting elements inside and they stack in the
card's flex column with --card-gap spacing. Use an
<article>
element when the card's content stands alone (as most do);
<div> is fine for purely decorative or
non-document contexts.
Track progress across sprints. Update statuses on a weekly cadence, push blockers to the top, and let the team see where velocity pools.
<div class="demo-card-solo">
<article class="card">
<h3>Project milestones</h3>
<p>Track progress across sprints. Update statuses on a weekly cadence, push blockers to the top, and let the team see where velocity pools.</p>
</article>
</div>
.is-compact tightens padding, gap, and font size
one step. Use it when cards nest inside a denser surface --
a sidebar panel, a settings list, a data-heavy dashboard --
where the default padding would feel out of scale.
Two modifiers tune the card's visual weight:
.is-flat strips the background, border, and
shadow for cards that sit on an already-raised surface (or
for grouping content without visually framing it);
.is-featured replaces the default border with a
primary-colored stroke to pull a card out of a grid as the
recommended choice.
Background, border, radius, and a subtle shadow.
Tighter padding, gap, and font size.
No bg, border, or shadow. Sits on a parent surface.
Primary 2px border. Pulls out of a grid.
<div class="card-grid">
<article class="card">
<h4>Default</h4>
<p>Background, border, radius, and a subtle shadow.</p>
</article>
<article class="card is-compact">
<h4>Compact</h4>
<p>Tighter padding, gap, and font size.</p>
</article>
<article class="card is-flat">
<h4>Flat</h4>
<p>No bg, border, or shadow. Sits on a parent surface.</p>
</article>
<article class="card is-featured">
<h4>Featured</h4>
<p>Primary 2px border. Pulls out of a grid.</p>
</article>
</div>Card has two structural regions, both expressed by semantic HTML elements -- no slot classes. CSS targets the elements directly.
<figure>)
A
<figure>
placed as the first child of the card receives a full-bleed
treatment: negative margins equal to the card's padding pull
it flush with the card's border, regardless of horizontal or
vertical padding tokens. Use for a hero image, a chart preview,
or any visual that should fill the card's width edge-to-edge.
<img>,
<picture>,
and <video> all work inside the figure;
<figcaption> is available when the image
needs a caption.
A <figure> at any other position in the
card stays at normal proportions (margins reset, no bleed),
so a mid-card figure won't visually break the layout.
Handoff complete; staging deploy scheduled for Thursday.
<div class="demo-card-solo">
<article class="card">
<figure>
<img src="https://picsum.photos/seed/card-figure/400/200" alt="" width="400" height="200"/>
</figure>
<h3>Launch plan</h3>
<p>Handoff complete; staging deploy scheduled for Thursday.</p>
</article>
</div>
The alt attribute is empty on the demo because
the image is decorative. In production, describe the image
meaningfully or leave alt="" if it's purely
aesthetic -- a missing or boilerplate alt
("image", "photo") fails assistive tech.
<footer>)
A
<footer>
inside the card is pushed to the card's bottom edge via
margin-block-start: auto. It carries no other
styling defaults; compose what's inside the way the content
wants. For an action row, a
.cluster of
buttons. For a byline,
.text-muted on a paragraph or span.
Your draft is ready to go live. Review once more, then publish to the staging site for final checks before release.
<div class="demo-card-solo">
<article class="card">
<figure>
<img src="https://picsum.photos/seed/card-footer/400/200" alt="" width="400" height="200"/>
</figure>
<h3>Publish draft</h3>
<p>Your draft is ready to go live. Review once more, then publish to the staging site for final checks before release.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Save for later</button>
<button class="button" type="button">Publish</button>
</footer>
</article>
</div>
When the whole card should act as a click target -- a gallery
tile, an article preview, a dashboard item linking to a detail
page -- add .is-interactive to the card root and
put the actual <a> on the heading. Don't
wrap the card itself in an anchor.
.is-interactive does two things. First, it styles
the card as clickable: cursor, hover shadow, hover border.
Second, it extends the heading anchor's click area over the
whole card via an absolutely-positioned
::after that covers the card's bounds. The
heading stays the single accessible link -- screen readers
announce just the heading text as the link label -- and
clicking anywhere on the card triggers the same link.
Highlights, misses, and commitments from the quarterly review. Four action items assigned; owner dates in the linked doc.
Outstanding items before the 1.0 cut. Click the card anywhere to open the checklist; the action buttons keep their own click targets.
<docs-resize>
<div class="card-grid" style="--card-grid-min: 20rem;">
<article class="card is-interactive">
<figure>
<img src="https://picsum.photos/seed/card-interactive/400/200" alt="" width="400" height="200"/>
</figure>
<h3><a href="#">Q4 retro summary</a></h3>
<p>Highlights, misses, and commitments from the quarterly review. Four action items assigned; owner dates in the linked doc.</p>
</article>
<article class="card is-interactive">
<figure>
<img src="https://picsum.photos/seed/card-interactive-footer/400/200" alt="" width="400" height="200"/>
</figure>
<h3><a href="#">Release checklist</a></h3>
<p>Outstanding items before the 1.0 cut. Click the card anywhere to open the checklist; the action buttons keep their own click targets.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Snooze</button>
<button class="button" type="button">Mark complete</button>
</footer>
</article>
</div>
</docs-resize>
Nested interactive elements inside
.is-interactive: buttons and links
inside the <footer> remain clickable --
the CSS gives the footer its own positioning context so its
children paint above the stretched ::after. Any
other nested interactive (a link inside the body, an
icon-button overlay) needs position: relative
on itself to escape the stretched layer. If the pattern
you're building has several overlapping interactives, drop
.is-interactive -- use the heading anchor as a
normal link and let each nested control carry its own click
target.
Multiple cards lay out in a responsive grid with
.card-grid: auto-fill columns with a minimum
width set by --card-grid-min. Cards in the same
row stretch to equal height, and each card's footer pins to
its bottom edge, so the plain grid stays tidy even when cards
have different regions — some with a figure, some
without, some with no footer. For most runs of cards this is
all you need.
Review role assignments and stale invitations before the security review on Friday.
Staged ship plan with go/no-go criteria for each phase. No figure on this card; the heading starts at the top and the footer still pins to the bottom edge.
No figure, no footer. The card still stretches to match the row's height.
<docs-resize>
<div class="card-grid">
<article class="card">
<figure>
<img src="https://picsum.photos/seed/plain-1/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Access control audit</h4>
<p>Review role assignments and stale invitations before the security review on Friday.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<h4>Rollout checklist</h4>
<p>Staged ship plan with go/no-go criteria for each phase. No figure on this card; the heading starts at the top and the footer still pins to the bottom edge.</p>
<footer class="cluster gap-snug">
<button class="button" type="button">Continue</button>
</footer>
</article>
<article class="card">
<h4>Docs migration</h4>
<p>No figure, no footer. The card still stretches to match the row's height.</p>
</article>
</div>
</docs-resize>
Add .is-aligned when you also want the regions
inside the cards to align across the row: every
title, body block, and footer starting at the same height in
each sibling. Cards switch to
grid-template-rows: subgrid, inheriting rows
whose heights are set by the tallest content in each position
— alignment without per-card measuring.
Body-length differences alone won't show the effect: the
footer's auto margin already absorbs those in the plain grid.
The difference appears when a region above another
varies in height. The demo below renders the same three cards
twice; the first title is long enough to wrap. In the plain
grid, the wrapped title pushes only its own card's body down.
With .is-aligned, the heading band is sized by
the tallest title, so every body starts on the same line.
Resize to see the wrap point move.
Plain .card-grid — the wrapped title pushes its own body down
Review role assignments before Friday.
Staged ship plan with go/no-go criteria for each phase.
Move remaining pages from the old CMS.
With .is-aligned — every body starts below the tallest title
Review role assignments before Friday.
Staged ship plan with go/no-go criteria for each phase.
Move remaining pages from the old CMS.
<docs-resize>
<div class="flow">
<p class="text-muted text--1">Plain <code>.card-grid</code> — the wrapped title pushes its own body down</p>
<div class="card-grid">
<article class="card">
<h4>Access control and stale invitation audit in a project</h4>
<p>Review role assignments before Friday.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<h4>Rollout checklist</h4>
<p>Staged ship plan with go/no-go criteria for each phase.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<h4>Docs migration</h4>
<p>Move remaining pages from the old CMS.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
</div>
<p class="text-muted text--1">With <code>.is-aligned</code> — every body starts below the tallest title</p>
<div class="card-grid is-aligned" style="--card-grid-rows: 3">
<article class="card">
<h4>Access control and stale invitation audit in a project</h4>
<p>Review role assignments before Friday.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<h4>Rollout checklist</h4>
<p>Staged ship plan with go/no-go criteria for each phase.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<h4>Docs migration</h4>
<p>Move remaining pages from the old CMS.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
</div>
</div>
</docs-resize>
The same comparison with full cards: figure, heading, body,
footer. This is the structure the default
--card-grid-rows of 4 assumes, so
.is-aligned needs no override here. The figures
share an aspect ratio and contribute no height difference;
the wrapped title is again what moves the bands.
Plain .card-grid
Review role assignments before the security review on Friday.
Staged ship plan with go/no-go criteria for each phase. Sign-off required before each window.
Move remaining pages from the old CMS; redirect map drafted.
With .is-aligned
Review role assignments before the security review on Friday.
Staged ship plan with go/no-go criteria for each phase. Sign-off required before each window.
Move remaining pages from the old CMS; redirect map drafted.
<docs-resize>
<div class="flow">
<p class="text-muted text--1">Plain <code>.card-grid</code></p>
<div class="card-grid">
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-1/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Access control and stale invitation audit in a project</h4>
<p>Review role assignments before the security review on Friday.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-2/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Rollout checklist</h4>
<p>Staged ship plan with go/no-go criteria for each phase. Sign-off required before each window.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
<button class="button" type="button">Continue</button>
</footer>
</article>
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-3/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Docs migration</h4>
<p>Move remaining pages from the old CMS; redirect map drafted.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
</div>
<p class="text-muted text--1">With <code>.is-aligned</code></p>
<div class="card-grid is-aligned">
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-1/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Access control and stale invitation audit in a project</h4>
<p>Review role assignments before the security review on Friday.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-2/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Rollout checklist</h4>
<p>Staged ship plan with go/no-go criteria for each phase. Sign-off required before each window.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
<button class="button" type="button">Continue</button>
</footer>
</article>
<article class="card">
<figure>
<img src="https://picsum.photos/seed/grid-3/400/180" alt="" width="400" height="180"/>
</figure>
<h4>Docs migration</h4>
<p>Move remaining pages from the old CMS; redirect map drafted.</p>
<footer class="cluster gap-snug">
<button class="button is-outlined" type="button">Open</button>
</footer>
</article>
</div>
</div>
</docs-resize>
.is-aligned requires uniform cards: subgrid
placement is positional, so every card must have the same
regions in the same order. A card missing its figure would
put its heading in the figure-height row. The default row
count assumes figure, heading, body, and footer; adjust
--card-grid-rows if your cards share a different
count. For mixed-structure cards, use the plain
.card-grid.
.card -- the raised surface. Render as
<article> for standalone content,
<div> for non-document contexts..card-grid -- auto-fill grid parent;
equal-height cards per row..card-grid.is-aligned -- adds subgrid row
alignment across uniform cards. See
Aligned card grid.<figure> (first child of .card) --
full-bleed media slot. <img>,
<picture>, <video>,
and <figcaption> all work inside.
Slot keyed by element. See
Media.<footer> (inside .card) --
pushed to the card's bottom edge. No styling defaults;
compose .cluster + buttons, or
.text-muted for byline. Slot keyed by
element. See Footer..is-compact -- density variant..is-flat -- transparent chrome variant..is-featured -- primary border variant..is-interactive -- clickable-card styling
plus stretched-link behavior: the card gets hover shadow
and cursor, and the heading's <a>
extends its hit area over the whole card via an
absolutely-positioned ::after. Put the
anchor on the heading; don't wrap the card itself in
<a>.
Card exposes scoped tokens for color (background, border,
shadow), sizing (padding block/inline, radius, gap), typography
(heading size, content font size), and the figure slot
(radius, optional explicit height). .card-grid
adds two of its own: a minimum column width and, for
.is-aligned, a subgrid row span. Density and
chrome variants remap the card tokens; the
figure's negative margins read --card-padding-inline
and --card-padding-block, so altering the padding
flows through to the full-bleed effect automatically.
Full token list and defaults:
src/css/card.css.
h3
because it looks right. Card's --card-heading-size
normalizes visual size, so any level reads consistently.
alt="" is correct when the image is purely
decorative (as in these demos). A meaningful
alt is required when the image carries
information not present in surrounding text.
Never leave alt off entirely, and avoid
placeholder words like "image" or "photo".
.is-interactive extends the heading
anchor's hit area over the whole card, so the click
target is wide without sacrificing the short, scannable
link name screen readers announce. Wrapping the card
itself in <a> would make the link's
accessible name the card's entire text content and
forbid any nested interactive element -- both
regressions over linking the heading.
.is-interactive. Buttons and links
inside <footer> paint above the
stretched layer and work as expected. Other nested
interactives (a link in the body, an icon-button overlay)
need position: relative on themselves to
escape the stretched layer. If your card has many
overlapping interactive elements, don't use
.is-interactive -- keep the heading anchor
as a plain link and let each nested control carry its
own click target.
Elevated container for grouped content
Source: src/css/card.css
| Token | Default | Description |
|---|---|---|
--card-bg | var(--color-surface-raised) | Background color for the card |
--card-border | var(--color-border) | Border color for the card |
--card-radius | var(--radius-md) | Border radius for the card corners |
--card-padding-block | var(--ui-pad-y-relaxed) | Vertical padding inside the card |
--card-padding-inline | var(--ui-pad-x-relaxed) | Horizontal padding inside the card |
--card-shadow | var(--shadow-sm) | Box shadow for the card |
--card-gap | var(--gap-relaxed) | Vertical spacing between card regions |
--card-heading-size | var(--step-1) | Font size for the card heading |
--card-content-font-size | inherit | Font size for the card body content |
--card-media-radius | var(--radius-md) | Border radius for the first-child media figure |
--card-media-height | auto | Fixed height for the media image or video |
| Slot | Description |
|---|---|
> figure:first-child | Full-bleed slot when figure is the first child |
> footer | The footer slot is pushed to the bottom edge |
| Class | Description |
|---|---|
.card | component root |
.is-compact | Tighter padding, gap, and smaller heading |
.is-flat | No shadow, border, or background |
.is-interactive | Hover shadow and stretched heading link for whole-card click |
.is-featured | Primary-colored thick border for emphasis |
Auto-fill grid of equal-height cards
Source: src/css/card.css
| Token | Default | Description |
|---|---|---|
--card-grid-min | 18rem), 1fr | Minimum column width for auto-fill columns |
--card-grid-rows | 4 | Number of subgrid rows each card spans |
| Class | Description |
|---|---|
.card-grid | component root |
.card-grid.is-aligned | Subgrid row alignment across uniform cards |