Tailwind CSS Handbook v4.3

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#

Loading preview…

Four utilities carry the whole thing:

UtilityWhy
rounded-xlSofter than rounded-md, still architectural
border border-gray-200The colour is required — v4 borders default to currentColor
p-6One padding value for the whole card; nudge with margins inside
shadow-smv4'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.

HTML
<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-hidden on the card is what clips the image to the rounded corners. Without it, the image square corners poke through.
  • aspect-video (16/9), aspect-square and aspect-[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.

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.

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

  1. relative on the card gives the overlay something to cover.
  2. after:absolute after:inset-0 on the link stretches its hit area across the card.
  3. relative on the nested link lifts it above the overlay so it stays clickable.
  4. 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.

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

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

HTML
<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-col on the card, mt-auto on the footer. That is the whole trick.
  • h-full matters 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 card flex-1 or a basis-*, then the same flex flex-col + mt-auto inside.
  • To stop long titles from setting the card width, add min-w-0 — flex and grid items default to min-width: auto.

Horizontal cards#

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

Loading preview…

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#

Loading preview…

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.

Loading preview…

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#

Loading preview…

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 a role="status" element with sr-only text beside them. Otherwise the loading state is silent.
  • motion-reduce:animate-none respects prefers-reduced-motion.
  • Use widths like w-2/3 and w-5/6 rather 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-hidden on 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 no focus-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.
  • border with no colour. In v4 that is currentColor, not a light grey.
  • shadow-sm copied from v3. v4's shadow-sm is bigger; the old value is shadow-xs.
  • Missing min-w-0 on the text column of a horizontal card, so truncation silently does nothing.