Content-Aware Card Layouts With :has(): One Component, Many Shapes
A design system's card component starts simple and accretes modifiers: card--with-image, card--no-image, card--with-badge, card--has-actions, card--long-title. Each one exists because the layout genuinely needs to differ, and each one is a fact the CMS template must remember to pass. When an editor removes the image but the class stays, the card reserves a blank media slot. This page shows how to replace content-describing modifiers with :has(), so the card reads its own contents and lays itself out correctly every time. It is part of the Mastering Container Queries & Responsive Layouts guide.
Why the card should look at itself
A modifier class is a second copy of information that already exists in the markup. card--with-image says "there is an image in here", and so does the <img> element. Two copies can disagree, and in real content pipelines they do: previews render without the image, A/B tests strip badges, translations lengthen titles past the point a --long-title class was assigned.
:has() lets the card consult the only authoritative copy. .card:has(> .card__media) is true exactly when the media element is present, no more and no less. The template's job shrinks to emitting the content; the stylesheet decides the layout from what arrived.
There is a boundary to respect. Some variants are editorial decisions that leave no trace in the content — "this card is featured", "this card uses the dark theme". Those still need a class or a data attribute, because there is nothing in the children for :has() to detect. The rule of thumb: if you could determine the variant by looking at the card's children, use :has(); if you could not, keep the class.
The shapes one card can take
Each shape comes from one question the card asks about its direct children. The questions are independent, so the variants compose: a card with media, a badge and actions gets all three adjustments without a combined modifier like card--image-badge-actions.
The complete implementation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content-aware cards</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 1rem;
}
.card {
position: relative; /* anchor for the badge */
display: grid;
/* Default: a single body row. */
grid-template-rows: 1fr;
border: 1px solid #cbd5e1;
border-radius: 12px;
overflow: hidden;
}
.card__media {
aspect-ratio: 16 / 9; /* space is reserved before the image loads */
background: linear-gradient(135deg, #bfdbfe, #818cf8);
}
.card__body { padding: 1rem; }
.card__title { margin: 0 0 0.25rem; font-size: 1.05rem; }
.card__text { margin: 0; color: #475569; }
/* Has media: add a media row above the body. */
.card:has(> .card__media) {
grid-template-rows: auto 1fr;
}
/* No media: give the body a coloured top rule so the card
still has a visual anchor instead of looking unfinished. */
.card:not(:has(> .card__media)) {
border-top: 4px solid #6366f1;
}
/* Has a badge: overlay it on the media if media exists... */
.card__badge {
justify-self: start;
margin: 1rem 1rem 0;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: #1e293b;
color: #f8fafc;
font-size: 0.75rem;
}
.card:has(> .card__media) > .card__badge {
position: absolute;
inset: 0.75rem auto auto 0.75rem;
margin: 0;
}
/* Has a footer: pin it to the bottom with its own row. */
.card:has(> .card__actions) {
grid-template-rows: 1fr auto;
}
.card:has(> .card__media):has(> .card__actions) {
grid-template-rows: auto 1fr auto;
}
.card__actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid #e2e8f0;
}
/* A selector cannot measure text length, so a long title is the one
variant that still needs a flag from the template. */
.card:has(.card__title[data-long]) .card__title { font-size: 0.95rem; }
</style>
</head>
<body>
<div class="cards">
<article class="card">
<div class="card__body"><h3 class="card__title">Text only</h3>
<p class="card__text">No media, so a coloured top rule appears.</p></div>
</article>
<article class="card">
<div class="card__media" role="img" aria-label="Abstract gradient"></div>
<div class="card__body"><h3 class="card__title">With media</h3>
<p class="card__text">A media row is added above.</p></div>
</article>
<article class="card">
<div class="card__media" role="img" aria-label="Abstract gradient"></div>
<span class="card__badge">New</span>
<div class="card__body"><h3 class="card__title">Media and badge</h3>
<p class="card__text">The badge floats over the media.</p></div>
</article>
<article class="card">
<div class="card__body"><h3 class="card__title">With actions</h3>
<p class="card__text">A footer row is pinned to the bottom.</p></div>
<div class="card__actions"><a href="#">Read</a><a href="#">Save</a></div>
</article>
</div>
</body>
</html>
The combined rule .card:has(> .card__media):has(> .card__actions) handles the three-row case. Chaining two :has() pseudo-classes means "both conditions", and it avoids a single complicated argument. Without it, the media rule and the actions rule would each set a two-row template and the later one would win, dropping one of the rows.
The badge rule shows the other useful shape: a child's placement depending on a sibling's presence. The badge sits in normal flow when there is no media, and becomes an overlay only when there is media to overlay. This is the kind of logic that previously needed the template to know about both elements at once.
The key technique: presence, absence and composition
The child combinator deserves emphasis again. Cards nest: a card might contain a list of related cards, each with its own media. With .card:has(.card__media), the outer card would match because of an inner card's image, and gain a media row it does not have. .card:has(> .card__media) looks only at its own direct children, which is what component boundaries require. The general principle is covered in
Migrating a component library off modifier classes
In an established design system the modifiers are part of a public API: product teams pass variant="with-image" to a card component, and removing that prop breaks their code. The migration therefore has to be additive first and subtractive later.
Start by adding the :has() rules alongside the existing modifier rules, and make them produce exactly the same declarations. At this stage both mechanisms agree whenever the template is correct, and where the template is wrong — a with-image card with no image — the two now conflict. Put the :has() rules later in the stylesheet, or in a later cascade layer, so the content-derived answer wins. Every card whose appearance changes during this step was a card that was already rendering incorrectly, which makes the diff a free audit of template bugs.
Next, deprecate the props in the component's documentation and make the component ignore them — the class may still be emitted for backward compatibility, but no rule reads it. Finally, after a release cycle, remove the classes and the dead modifier rules together.
Two things tend to surface during the audit. First, some "content" modifiers turn out to be editorial after all: a card marked with-image whose image is decorative and hidden on small screens, say. Those become explicit data attributes such as data-media="decorative", which is honest about being a decision rather than a fact. Second, tests that snapshot class names start failing even though nothing visible changed. Switching those tests to screenshot or accessibility-tree assertions is worth doing anyway, because class names were never the contract that mattered.
For teams using cascade layers, the cleanest arrangement is a components layer for the base card and a later components.content sub-layer holding the :has() rules, so the precedence between the two mechanisms is stated structurally rather than depending on source order in a bundle.
Variation: switching orientation with a container query
Content decides which rows exist; width decides how they are arranged. Adding a container query makes a card with media switch to a side-by-side layout when it has room, while a text-only card stays as it is.
.card-wrapper { container-type: inline-size; }
/* Wide AND has media: image on the left, body on the right. */
@container (width > 30rem) {
.card:has(> .card__media) {
grid-template-columns: minmax(10rem, 40%) 1fr;
grid-template-rows: 1fr;
}
.card:has(> .card__media) > .card__media {
aspect-ratio: auto; /* fill the row height instead */
height: 100%;
}
.card:has(> .card__media):has(> .card__actions) > .card__actions {
grid-column: 2;
}
}
The two mechanisms never conflict because they answer different questions. The query establishes that there is room; the :has() establishes that there is something worth putting in that room. For a full treatment of the width side, see Building Responsive Cards With Container Queries. If the card should animate between shapes as content changes — a badge appearing, actions revealing on hover — smooth hover effects without JavaScript covers the transition side.
Browser support
:has() is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. aspect-ratio, which reserves media space, is supported in Chrome and Edge 88+, Firefox 89+ and Safari 15+. Container queries need Chrome and Edge 105+, Firefox 110+ or Safari 16+. In an engine without :has(), cards fall back to the single-row default: media and body still stack in source order because grid auto-places extra children into implicit rows, badges sit in flow, and nothing is hidden, so the component degrades to plain but complete.
FAQ
Should I replace all card modifier classes with :has()?
Replace the ones that merely describe content, such as card--with-image or card--no-footer, because :has() can detect that content directly and cannot go out of sync. Keep classes for editorial choices that are not visible in the markup, such as a featured card that should be larger.
Can :has() check whether an image has loaded?
No. :has(img) matches as soon as the element is in the DOM, whether or not the file has downloaded or failed. Reserve space with width and height attributes or aspect-ratio so the layout is correct before and after loading.
How do I detect an empty element with :has()?
Use :empty for an element with no children or text at all, or :not(:has(*)) for an element with no child elements. Whitespace counts as content for :empty in current engines' long-standing behaviour, so a template that leaves a newline inside an otherwise empty div will not match :empty.
Does a card that changes layout by content still need container queries?
Usually yes, because the two answer different questions. :has() responds to what the card contains; a container query responds to how much room it has. A card with media should still switch from stacked to side-by-side based on its width.
Related
- Parent-Aware Layouts With
() — the parent guide to relational selectors. - Quantity Queries With
() — layouts driven by how many children exist. - Building Responsive Cards With Container Queries — the width half of the same component.
- Subgrid vs Nested Grid — keeping rows aligned across a grid of these cards.
- Smooth Hover Effects Without JavaScript — motion for the card once its shape is settled.
Related articles
More pages in the same section.