Cards
Composing surface, content, and action into card layouts
Cards group related content and optional actions onto a raised surface. They are a composition pattern — not a primitive — built entirely from semantic tokens and the Button / ButtonLink primitives.
Basic card
A card with a header, body text, and no action.
Project overview
A summary of your current project status, recent activity, and upcoming milestones.
<div class="bg-white rounded-xl p-6 max-w-80">
<h3 class="text-h5 mb-[0.35em]">Project overview</h3>
<p class="text-p text-dark/70 m-0">A summary of your current project status, recent activity, and upcoming milestones.</p>
</div>Anatomy
A card is divided into three regions:
┌─────────────────────────────────────┐
│ Header — title + optional eyebrow │
├─────────────────────────────────────┤
│ Body — main content / media │
├─────────────────────────────────────┤
│ Footer — actions or metadata │
└─────────────────────────────────────┘Surface tokens used in a card:
| Region | Token |
|---|---|
| Background | bg-white |
| Border | border-dark/12 |
| Drop shadow | shadow-sm (shadow-md on hover) |
| Header / footer dividers | border-dark/12 |
Spacing:
| Role | Class |
|---|---|
| Inner padding | p-6 |
| Header–body / body–footer gap | pb-4 on the body, py-4 on the footer |
| Card corner radius | rounded-xl |
Card with action
Place actions in the footer, separated from the body by a top border. Use Button for in-page actions and ButtonLink for navigation.
Deploy to production
Your build passed all checks. Review the diff before deploying.
<div class="bg-white rounded-xl max-w-80 flex flex-col">
<div class="px-6 pt-6 pb-4">
<h3 class="text-h5 mb-[0.35em]">Deploy to production</h3>
<p class="text-p text-dark/70 m-0">Your build passed all checks. Review the diff before deploying.</p>
</div>
<div class="flex justify-end gap-3 border-t border-dark/12 px-6 py-4">
<Button variant="outlined" size="sm">Review diff</Button>
<Button variant="primary" size="sm">Deploy</Button>
</div>
</div>Card grid
Use CSS grid with auto-fit columns so the layout adapts from one column on narrow viewports to three on wide ones.
Storage
4.2 GB
of 10 GB used
Requests
128k
last 30 days
Uptime
99.9%
over 90 days
<div class="grid w-full gap-8 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
<div class="bg-white rounded-xl p-6">
<p class="text-eyebrow text-dark/55 mb-[0.5em]">Storage</p>
<p class="text-h4 mb-[0.35em]">4.2 GB</p>
<p class="text-p text-dark/70 m-0">of 10 GB used</p>
</div>
<div class="bg-white rounded-xl p-6">
<p class="text-eyebrow text-dark/55 mb-[0.5em]">Requests</p>
<p class="text-h4 mb-[0.35em]">128k</p>
<p class="text-p text-dark/70 m-0">last 30 days</p>
</div>
<div class="bg-white rounded-xl p-6">
<p class="text-eyebrow text-dark/55 mb-[0.5em]">Uptime</p>
<p class="text-h4 mb-[0.35em]">99.9%</p>
<p class="text-p text-dark/70 m-0">over 90 days</p>
</div>
</div>Interactive card
A fully clickable card wraps its primary content in an <a> element (the “stretched link” technique). The anchor fills the card via position: absolute; inset: 0, and any secondary inline actions use position: relative with a higher z-index to remain independently clickable.
Guide
Getting started with tokens
Learn how primitive, semantic, and component tokens work together in Koval Core.
<div class="group bg-white rounded-xl p-6 max-w-80 relative">
<p class="text-eyebrow text-dark/55 mb-[0.5em]">Guide</p>
<h3 class="text-h5 mb-[0.35em] transition-colors duration-200 ease-out group-hover:text-primary">
<a
href="#"
class="text-inherit no-underline focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-primary"
>Getting started with tokens</a>
</h3>
<p class="text-p text-dark/70 m-0">Learn how primitive, semantic, and component tokens work together in Koval Core.</p>
</div>Stretched link rules:
- The
<a>covers the card withafter:absolute after:inset-0, so no separate<style>block is needed. The card parent carriesrelative, andafter:rounded-xlkeeps the hit area on the card’s own corners. focus-visible:outline-noneon the anchor moves the ring tofocus-visible:after:outline-2 …, so it draws around the whole card rather than around the title text. This is the one place the outline moves — it is never removed.- Keep the link text meaningful — it becomes the accessible name for the entire card click target.
- Any secondary actions inside the card (e.g. a “Bookmark” icon button) must use
position: relativeand a higherz-indexso they sit above the stretched link and remain independently activatable. - Do not nest another
<a>or<button>inside an interactive card without isolating it withposition: relative; z-index: 1.
On inverse surfaces
Use background="dark" on ComponentPreview or apply data-on-inverse to the wrapping element. Replace surface and border tokens with their inverse counterparts; button children inherit inverse styling from that surface context, with appearance="inverse" reserved for isolated controls that cannot inherit it.
Invite your team
Share access and collaborate on projects in real time.
<div class="bg-white/8 rounded-xl max-w-80 flex flex-col">
<div class="px-6 pt-6 pb-4">
<h3 class="text-h5 text-white mb-[0.35em]">Invite your team</h3>
<p class="text-p text-white/58 m-0">Share access and collaborate on projects in real time.</p>
</div>
<div class="flex justify-end gap-3 border-t border-white/28 px-6 py-4">
<Button variant="outlined" size="sm">Later</Button>
<Button variant="primary" size="sm">Send invites</Button>
</div>
</div>Token reference
| Class | Role | What it resolves to |
|---|---|---|
bg-white | Card background (light surface) | Tailwind white — a card is white on the light page ground |
bg-white/8 | Card background on inverse | 14% white composited over dark |
border-dark/12 | Card border on light surface | 12% dark |
border-white/28 | Card border on inverse surface | 28% white |
rounded-xl | Card corner radius | The surface tier of the radius convention |
p-6 | Card inner padding | 24px |
px-6 py-4 | Footer padding | 24px / 16px |
gap-8 | Gap between cards in a grid | The spacing convention’s grid gap |
mb-[0.35em] | Title → its own body text | em, so it tracks the type |
Radius, padding and gap come from theme-rules.md, not from a token — they are
Tailwind’s own utilities, chosen once. Retheme the project and these change with it.
A card carries no border and no shadow. It is bg-white on the light page
ground, and the ground change is the boundary — that is the whole zoning
convention, and a card is where it is easiest to see. The one hairline below is a
footer rule, which is the documented exception: two zones on the same ground that
genuinely cannot be told apart otherwise. Turn borders or elevation on in the theme
builder and every card gains them together.
Accessibility
Heading hierarchy
Every card that contains a visible title should use a heading element (<h2>–<h4>) whose level fits the surrounding page outline. Do not skip heading levels. A card grid of three peer cards should all use the same heading level.
- Use
<h2>for top-level section cards on a page with a single<h1>. - Use
<h3>for cards nested inside a named section. - Use
<p>(not a heading) for metadata-only cards (stats, KPI tiles) where the label is decorative rather than structural.
The class is a separate decision from the tag. A card title is an <h3> because
of where it sits in the page outline, and text-h5 because of how much weight it
should carry — a card title is not a section heading. Do not let one choose the
other, and never leave a card title with no role class at all: .prose does not
reach inside a preview, so a bare <h3> renders at body size.
Interactive card keyboard access
For the stretched-link pattern:
- The
<a>element inside the card must be focusable and have a meaningful accessible name (its text content). - The
::afterpseudo-element that covers the card must not itself be a separate focusable element — only the<a>receives focus. - Visually indicate focus with
focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-primary, on the::afterpseudo-element, so the ring follows the full card boundary. - If the card contains secondary actions (icon buttons, “Bookmark”, “More”), each must be independently focusable and have its own
aria-label. Stack order: the stretched link sits below (z-index: 0), secondary actions sit above (z-index: 1).
Color and contrast
- Never use color as the sole differentiator between card states (e.g. selected vs unselected). Pair color with a border-width change, checkmark icon, or
aria-pressed/aria-selected. - Text inside cards must meet WCAG AA contrast on their respective surface tokens:
text-darkonbg-whiteandtext-whiteonbg-dark.