Card Patterns
Cards are the workhorse component of modern web UI — product tiles, articles, profile summaries, pricing plans. They look simple, but a production-ready card has to handle variable content length, images that may not exist, hover and focus states, and a grid of cards that all need to line up despite different amounts of text. This page builds up from the simplest card to a full responsive card grid.
The basic card
A card is a padded box with a border or shadow that visually groups related content. That is the entire concept — everything else is refinement.
<div class="card">
<h3 class="card-title">Card title</h3>
<p class="card-body">
Some supporting text that describes the card's content in a sentence
or two.
</p>
<a href="#" class="card-link">Read more</a>
</div>.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
border-radius: 12px;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.card-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.card-body {
margin: 0;
color: #555;
line-height: 1.5;
}
.card-link {
margin-top: auto;
font-weight: 600;
color: #2563eb;
text-decoration: none;
}flex-direction: column lets you push a single child to the bottom by giving it margin-top: auto. That is what keeps the "Read more" link flush with the card's bottom edge even when the body text is short.Image card
Adding an image is where cards start to break in practice — inconsistent image dimensions cause uneven cards. Constrain the image with a fixed aspect-ratio and object-fit so the card shape stays predictable regardless of the source image size.
<article class="card card--image">
<img class="card-image" src="/thumb.jpg" alt="Descriptive alt text" />
<div class="card-content">
<h3 class="card-title">Article title</h3>
<p class="card-body">One or two lines of summary text go here.</p>
</div>
</article>.card--image {
padding: 0;
overflow: hidden;
}
.card-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
.card-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}aspect-ratio instead of a fixed height on the image. A fixed height breaks on unusual image proportions;aspect-ratio combined with object-fit: cover keeps the crop sensible no matter the source dimensions.Horizontal card
Search results and list-style layouts often need the image beside the text rather than above it. Flexbox on the row axis handles this directly.
.card--horizontal {
display: flex;
flex-direction: row;
padding: 0;
overflow: hidden;
}
.card--horizontal .card-image {
width: 140px;
aspect-ratio: 1;
flex-shrink: 0;
}
.card--horizontal .card-content {
padding: 16px;
min-width: 0; /* allow text to truncate instead of overflowing */
}
.card--horizontal .card-body {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}min-width: 0 on the flex child that holds the text lets it shrink and wrap or truncate properly.Clickable card (the pseudo-element trick)
Making an entire card clickable while keeping exactly one real link tag (good for accessibility and SEO) is done with a "stretched link" — ::after positioned to cover the whole card, linking through the parent's position: relative.
<article class="card card--clickable">
<h3 class="card-title">Card title</h3>
<p class="card-body">
Only this specific link is a real link; the whole card becomes
clickable through the pseudo-element below.
</p>
<a href="/details" class="card-stretched-link">View details</a>
</article>.card--clickable {
position: relative;
transition: box-shadow 150ms ease, transform 150ms ease;
}
.card-stretched-link::after {
content: "";
position: absolute;
inset: 0; /* top/right/bottom/left: 0 in one declaration */
}
.card--clickable:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.card--clickable:has(.card-stretched-link:focus-visible) {
outline: 2px solid #2563eb;
outline-offset: 2px;
}<a> is invalid when the card also contains other interactive elements (a secondary "Share" button, for example) and it also makes every word of the card read as one giant, confusing link for screen readers. The pseudo-element trick keeps one specific, well-labeled link while still making the whole card clickable visually.Card grid with equal heights
Grid, not flexbox, is the right tool when you need a row of cards to share equal height automatically — grid rows stretch children to the tallest item in the row by default.
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
align-items: stretch; /* default, but explicit for clarity */
}
.card-grid .card {
height: 100%; /* fill the stretched grid cell */
}Layout | Equal-height cards? | Notes |
|---|---|---|
Flexbox row | Yes, by default | Cross-axis stretch is the flex default |
Grid | Yes, by default |
|
Floats | No | Historically needed a "clearfix" and equal-height hacks |
Hover and focus effects
.card {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.card:hover,
.card:focus-within {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}:hover with :focus-within. Keyboard users tabbing into the card's link never trigger:hover, so without :focus-within they get no visual feedback that the card is active.Accessibility checklist
Every image needs meaningful
alttext, oralt=""if it is purely decorativeOnly one real, clearly-labeled link or button per card — avoid nested interactive elements
Ensure sufficient color contrast between card text and its background, especially on image overlays
Add a visible focus style (
:focus-visible) — do not rely on hover-only affordancesKeep heading levels consistent inside a repeated card grid (all
h3, for example) so the page outline stays logical