HTML & CSS82 min total · 18 parts
CSS Fundamentals: The Box Model, Specificity, Positioning, and Layout
Part 8 of 18 · ~2 min
Selectors Deep Dive: Combinators and Attribute Selectors
Once you're past plain type, class, and ID selectors, the next tool is combinators — and what a combinator matches is never a kind of element, it's a position one element holds relative to another:
.card .description { } /* descendant combinator (just a space) — matches .description at any depth under .card */
.card > h3 { } /* child combinator — matches h3 only one level down from .card, nothing deeper */
h3 + .price { } /* adjacent sibling — matches .price only when it comes immediately after an h3 */
h3 ~ .badge { } /* general sibling — matches any later .badge under that same parent, however far down the list */
The descendant combinator is the one you'll reach for constantly, and it's also the easiest one to make accidentally too broad. Here's the exact bug it caused on the category page. The intro blurb above the grid needed muted, slightly smaller text:
<section class="category-page">
<div class="intro">
<p>Trail-ready shoes for wet, rocky terrain.</p>
</div>
<div class="grid">
<article class="card">
<h3>Trail Runner 2</h3>
<p class="description">Breathable mesh upper, grippy outsole.</p>
</article>
<!-- 23 more cards -->
</div>
</section>
.category-page p { color: var(--muted-text); font-size: 0.9rem; } /* meant only for the intro */
.category-page p matches any <p> nested anywhere inside .category-page — including the card's own .description, which lives several levels deeper but is still, technically, a descendant. Every card on the page loses its normal-weight description text to the intro's muted styling, and the selector never announces the mistake; it just quietly reaches further than intended. The fix is to say what was actually meant:
.category-page > .intro p { color: var(--muted-text); font-size: 0.9rem; }
The child combinator (>) is the right tool whenever "direct child only" is the actual rule, and reaching for it by default in situations like this — rather than a bare descendant selector — is usually the safer habit once a page has more than one component nested inside another.
Attribute selectors reach directly into an element's markup attributes, which sidesteps having to invent and maintain a matching class for every state a data attribute already encodes:
.card[data-sale="true"] .price { color: var(--accent-color); } /* exact match on a data attribute */
input[type="number"] { text-align: center; } /* the cart drawer's quantity field */
[aria-live="polite"] { position: absolute; } /* the "Added to cart" toast, styled by its own role rather than a class */
.card[data-sale="true"] in particular means the sale styling is driven by the same data attribute a script would check to decide whether to show a ribbon at all — one source of truth instead of a class that has to be kept in sync with it by hand.