Cards
A base card recipe plus image, clickable, horizontal, stat, pricing and skeleton variants — including the container-query and stretched-link patterns.
A card is a border, some padding and a shadow. Almost everything interesting about it is what happens next: how it behaves when it is clickable, how it lines up with its neighbours, and how it reflows when the space changes.
The base recipe#
<div class="p-6">
<article class="mx-auto max-w-sm rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Deploy previews</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Every pull request gets its own URL, built from that branch and torn down when the branch merges.
</p>
<a href="#" class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">
Read the guide
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14m-6-6 6 6-6 6"/></svg>
</a>
</article>
</div>Four utilities carry the whole thing:
| Utility | Why |
|---|---|
rounded-xl | Softer than rounded-md, still architectural |
border border-gray-200 | The colour is required — v4 borders default to currentColor |
p-6 | One padding value for the whole card; nudge with margins inside |
shadow-sm | v4's shadow-sm is v3's shadow. Use shadow-xs for the old shadow-sm |
Cards with an image#
The image needs a fixed aspect ratio so a grid of cards does not jump around while images load, and object-cover so it fills that box without distortion.
<article class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<img src="/covers/aurora.jpg" alt="" class="aspect-video w-full object-cover" />
<div class="p-5">
<h3 class="font-semibold text-gray-900">Aurora release notes</h3>
<p class="mt-1 text-sm text-gray-600">What shipped in March.</p>
</div>
</article>overflow-hiddenon the card is what clips the image to the rounded corners. Without it, the image square corners poke through.aspect-video(16/9),aspect-squareandaspect-[4/3]all work; pick one per card type and stay with it.- A decorative image gets
alt="". If the image carries information the text does not, describe it.
Clickable cards#
The tempting version — wrapping the whole card in an <a> — is invalid the moment the card contains a second link or a button. Interactive elements cannot nest, browsers recover from it unpredictably, and a screen reader announces one enormous link whose name is every word in the card.
The stretched link pattern#
Put the link on the element that names the card — usually the heading — and let a pseudo-element cover the card. Everything stays valid, the accessible name of the link is the title, and clicking anywhere still works.
<article class="group relative rounded-xl border border-gray-200 p-6 transition hover:border-gray-300 hover:shadow-md has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-2 has-[a:focus-visible]:outline-indigo-600">
<h3 class="font-semibold text-gray-900">
<a href="/posts/1" class="after:absolute after:inset-0 focus-visible:outline-hidden">
Shipping a design system
</a>
</h3>
<p class="mt-2 text-sm text-gray-600">How we versioned tokens across three apps.</p>
<a href="/tags/css" class="relative mt-4 inline-block rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200">
CSS
</a>
</article>How it fits together:
relativeon the card gives the overlay something to cover.after:absolute after:inset-0on the link stretches its hit area across the card.relativeon the nested link lifts it above the overlay so it stays clickable.has-[a:focus-visible]:on the card moves the focus ring to the card, because the link itself is only as big as its text.
Hover effects with group#
group on the card lets every child respond to hovering the card as a whole.
<article class="group rounded-xl border border-gray-200 p-6 transition hover:border-indigo-300 hover:shadow-md">
<h3 class="font-semibold text-gray-900 transition group-hover:text-indigo-600">Analytics</h3>
<p class="mt-2 text-sm text-gray-600">Traffic, conversions and funnels.</p>
<span class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-indigo-600">
Open
<svg class="size-4 transition group-hover:translate-x-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" d="M5 12h14m-6-6 6 6-6 6"/></svg>
</span>
</article>Two notes. transition belongs on the base state of each animated element, not inside the group-hover: — otherwise it snaps on the way in and out. And hover is not available on touch or to keyboard users, so pair it with group-focus-within: whenever the card contains a link:
<h3 class="group-hover:text-indigo-600 group-focus-within:text-indigo-600">…</h3>Equal heights#
In a grid, tracks are the same height already and each card stretches to fill its cell — the problem is the content inside the card, which leaves ragged footers when descriptions differ in length.
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<article class="flex h-full flex-col rounded-xl border border-gray-200 p-6">
<h3 class="font-semibold text-gray-900">Starter</h3>
<p class="mt-2 text-sm text-gray-600">A short description.</p>
<!-- mt-auto pins the footer to the bottom of whichever card is tallest -->
<a href="#" class="mt-auto pt-4 text-sm font-medium text-indigo-600">Choose</a>
</article>
</div>flex flex-colon the card,mt-autoon the footer. That is the whole trick.h-fullmatters when the card is wrapped in another element inside the grid cell; a direct grid child already stretches.- In a flex row, add
items-stretch(the default) and give each cardflex-1or abasis-*, then the sameflex flex-col+mt-autoinside. - To stop long titles from setting the card width, add
min-w-0— flex and grid items default tomin-width: auto.
Horizontal cards#
<article class="flex items-start gap-4 rounded-xl border border-gray-200 p-4">
<div class="size-16 shrink-0 rounded-lg bg-linear-to-br from-indigo-500 to-purple-600"></div>
<div class="min-w-0">
<h3 class="truncate font-semibold text-gray-900">A title long enough to need truncating</h3>
<p class="mt-1 line-clamp-2 text-sm text-gray-600">Two lines of supporting copy, then an ellipsis.</p>
</div>
</article>shrink-0 on the media stops it being squashed; min-w-0 on the text column is what makes truncate and line-clamp-2 work at all.
Cards that reflow with container queries#
A card that switches layout on md: breaks the moment you drop it into a sidebar — the viewport is wide, the card is not. @container measures the card's own space instead.
<div class="space-y-6 bg-gray-50 p-6 dark:bg-gray-950">
<div class="@container mx-auto max-w-xl">
<article class="flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-4 @md:flex-row @md:items-center dark:border-gray-800 dark:bg-gray-900">
<div class="h-24 shrink-0 rounded-lg bg-linear-to-br from-sky-500 to-indigo-600 @md:h-20 @md:w-28"></div>
<div class="min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Wide slot</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Enough room, so the card lays out as a row.</p>
</div>
</article>
</div>
<div class="@container mx-auto max-w-xs">
<article class="flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-4 @md:flex-row @md:items-center dark:border-gray-800 dark:bg-gray-900">
<div class="h-24 shrink-0 rounded-lg bg-linear-to-br from-sky-500 to-indigo-600 @md:h-20 @md:w-28"></div>
<div class="min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Narrow slot</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Identical markup, stacked.</p>
</div>
</article>
</div>
</div>Same markup, two layouts, one browser width. Wrapping the outermost element of a reusable card in @container costs nothing until you use a variant, so it is worth doing by default.
Stat cards#
<div class="grid gap-4 p-6 sm:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Monthly revenue</p>
<p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-gray-100">$48,120</p>
<p class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-green-700 dark:text-green-400">
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 19V5m0 0-6 6m6-6 6 6"/></svg>
12.4%
<span class="sr-only">increase</span>
<span class="font-normal text-gray-500 dark:text-gray-400">vs last month</span>
</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active users</p>
<p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-gray-100">2,318</p>
<p class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-red-700 dark:text-red-400">
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m0 0 6-6m-6 6-6-6"/></svg>
3.1%
<span class="sr-only">decrease</span>
<span class="font-normal text-gray-500 dark:text-gray-400">vs last month</span>
</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. response</p>
<p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-gray-100">142ms</p>
<p class="mt-2 text-sm font-normal text-gray-500 dark:text-gray-400">No change</p>
</div>
</div>tabular-nums gives every digit the same width, so figures line up in a column and a live-updating number does not shimmy. The sr-only "increase" / "decrease" text is what makes the green and red arrows mean something without sight.
Pricing cards#
The featured plan needs a difference a screenshot can survive: a ring, a badge and a filled button — not just a slightly bluer border.
<div class="grid gap-6 p-6 sm:grid-cols-2">
<div class="flex flex-col rounded-2xl border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Starter</h3>
<p class="mt-3"><span class="text-4xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-gray-100">$0</span><span class="text-sm text-gray-500"> / month</span></p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Everything you need to try things out.</p>
<ul class="mt-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex gap-2"><svg class="size-5 shrink-0 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7"/></svg>1 project</li>
<li class="flex gap-2"><svg class="size-5 shrink-0 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7"/></svg>Community support</li>
</ul>
<div class="mt-auto pt-6">
<a href="#" class="block rounded-md border border-gray-300 px-4 py-2 text-center text-sm font-semibold text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800">Start free</a>
</div>
</div>
<div class="relative flex flex-col rounded-2xl bg-white p-6 ring-2 ring-indigo-600 dark:bg-gray-900">
<span class="absolute -top-3 left-6 rounded-full bg-indigo-600 px-3 py-1 text-xs font-semibold text-white">Most popular</span>
<h3 class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">Pro</h3>
<p class="mt-3"><span class="text-4xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-gray-100">$29</span><span class="text-sm text-gray-500"> / month</span></p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">For teams shipping every day.</p>
<ul class="mt-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex gap-2"><svg class="size-5 shrink-0 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7"/></svg>Unlimited projects</li>
<li class="flex gap-2"><svg class="size-5 shrink-0 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7"/></svg>Priority support</li>
</ul>
<div class="mt-auto pt-6">
<a href="#" class="block rounded-md bg-indigo-600 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-indigo-500">Start trial</a>
</div>
</div>
</div>flex flex-col plus mt-auto on the call to action keeps both buttons on the same line however long the feature lists are. The badge is positioned with absolute -top-3 left-6 against the card's relative, and the card uses ring-2 rather than border-2 so the emphasis does not change the card's box size and shift the layout.
Skeleton loading#
<div class="p-6">
<div role="status" class="mx-auto max-w-sm rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<div class="animate-pulse motion-reduce:animate-none" aria-hidden="true">
<div class="h-32 rounded-lg bg-gray-200 dark:bg-gray-800"></div>
<div class="mt-4 h-4 w-2/3 rounded bg-gray-200 dark:bg-gray-800"></div>
<div class="mt-2 h-3 w-full rounded bg-gray-200 dark:bg-gray-800"></div>
<div class="mt-2 h-3 w-5/6 rounded bg-gray-200 dark:bg-gray-800"></div>
<div class="mt-5 h-8 w-24 rounded-md bg-gray-200 dark:bg-gray-800"></div>
</div>
<span class="sr-only">Loading article…</span>
</div>
</div>Rules worth keeping:
- The skeleton should match the real card's box — same padding, same radius, same rough line heights — or the page jumps when content arrives.
- Mark the shapes
aria-hidden="true"and put arole="status"element withsr-onlytext beside them. Otherwise the loading state is silent. motion-reduce:animate-nonerespectsprefers-reduced-motion.- Use widths like
w-2/3andw-5/6rather than full-width bars; it reads as text rather than as a broken image.
Common mistakes#
- Nesting a link or button inside a card-wide link. Invalid HTML and unusable with a screen reader — use the stretched-link pattern.
- A
<div onclick>card. Not focusable, not keyboard-operable, announced as nothing. - Forgetting
overflow-hiddenon a card with an image, so square image corners escape the rounded card. - No fixed aspect ratio on images. The grid reflows as each image loads.
hover:effects with nofocus-within:equivalent. Keyboard and touch users never see them.- Fixed card heights. Use
line-clamp-*to limit text instead; a fixed height clips at the worst possible moment. borderwith no colour. In v4 that iscurrentColor, not a light grey.shadow-smcopied from v3. v4'sshadow-smis bigger; the old value isshadow-xs.- Missing
min-w-0on the text column of a horizontal card, so truncation silently does nothing.
Related topics#
- Grid — laying cards out
- Container Queries — cards that respond to their slot
- Shadows — the v4 shadow scale
- Dark Mode — pairing surface colours
- Buttons — the call to action inside the card