🔗 Descendant and Sibling Combinators
Attribute selectors and pseudo-classes describe an element on its own. Combinators describe an element by its relationship to another — "the paragraph inside this article", "the field right after that label". They let CSS follow the shape of your HTML, so you can style structure without inventing extra classes.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish the four combinators: descendant (space), child (
>), adjacent sibling (+), and general sibling (~) - Choose descendant vs. child selectors based on how tightly you want to bind to structure
- Apply sibling combinators to spacing, borders, and typography patterns
- Build an interactive toggle with the checkbox hack — zero JavaScript
- Reason about how combinators affect specificity and maintainability
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a pure-CSS accordion using a sibling combinator and :checked.
In This Lesson
What Is a Combinator?
A combinator is the character between two selectors that describes how they relate. In article p, the space is the combinator: it says "a <p> that lives somewhere inside an <article>". Change the character and you change the relationship.
💡 A useful analogy: Combinators are directions given by landmarks rather than street addresses. "The café next to the bank" (adjacent sibling) and "any shop inside the mall" (descendant) both locate a place by what it sits near — not by a fixed coordinate. Your HTML is the neighborhood; combinators are the directions through it.
Because they read the structure you already wrote, combinators keep your markup lean: no class="first-para-after-heading", just h2 + p.
The Four Combinators at a Glance
| Combinator | Symbol | Relationship | Example |
|---|---|---|---|
| Descendant | space | Nested at any depth | article p |
| Child | > | Direct child only | article > p |
| Adjacent sibling | + | The very next sibling | h2 + p |
| General sibling | ~ | Any later sibling | h2 ~ p |
💡 A note on the column combinator
Selectors Level 4 also drafts a column combinator (||) for table columns, but it has effectively no browser support in 2026. You can ignore it in practice — the four above are all you need.
Descendant vs. Child
The descendant combinator (a space) reaches any matching element nested inside the ancestor, no matter how deep. The child combinator (>) reaches only direct children — one level down, no further.
/* Every paragraph anywhere inside an article */
article p { line-height: 1.6; }
/* Only paragraphs that are DIRECT children of the article */
article > p { font-size: 1.1rem; }
| Descendant (space) | Child (>) |
|---|---|
| Matches at any nesting level | Matches direct children only |
| Resilient when you add wrapper markup | Precise, but breaks if you wrap the target |
| Great for broad content areas | Great for menus, grids, layout scaffolding |
⚠️ Descendant selectors leak
A nested menu is the classic trap: nav li { color: blue } colors the sub-menu items too. Reach for the child combinator — nav > ul > li — when you want only the top level, so deeper items don't inherit rules meant for the surface.
Adjacent Sibling (+)
The adjacent sibling combinator selects the element that comes immediately after another, sharing the same parent. Only the very next sibling qualifies.
/* The paragraph directly after a heading — a lead-in */
h2 + p { font-size: 1.15rem; font-weight: 500; }
/* Space a list only when it follows a paragraph */
p + ul { margin-top: 1.5rem; }
/* Collapse the doubled border between stacked cards */
.card + .card { border-top: none; }
That last pattern — the "lobotomized owl" family of spacing rules — is one of the most useful in all of CSS. Instead of putting a top margin on every card and then removing it from the first, you only add the border/margin between siblings:
/* Add gap between stacked items, but not above the first */
.stack > * + * { margin-top: 1rem; }
✅ Why * + * beats "margin on everything"
Putting a top margin on every child then zeroing the first is fragile — reorder the list and the exception moves. The adjacent-sibling version has no exception to maintain: the space exists only where two siblings meet, so it is always correct regardless of order or count.
General Sibling (~)
The general sibling combinator selects every later sibling that matches — not just the immediate one — as long as they share a parent and appear after the reference element.
/* Every paragraph that comes after this heading */
h2 ~ p { color: var(--text-light, #555); }
/* Dim every list item that follows the active one */
.active ~ li { opacity: 0.6; }
| Adjacent sibling (+) | General sibling (~) |
|---|---|
| Only the immediately following sibling | All following siblings |
| Precise, positional | Broad, group-wide |
h2 + p — the one lead paragraph | h2 ~ p — the whole section's paragraphs |
💡 Siblings only look forward
Both sibling combinators match elements that appear after the reference element in source order. There is no "previous sibling" combinator — CSS has never been able to look backward among siblings. If you need to react to something earlier, restructure the markup or reach for :has() from the previous lesson.
The Checkbox Hack
Here is where combinators become genuinely powerful. A hidden checkbox tracks an on/off state; the :checked pseudo-class plus a sibling combinator lets you style other elements based on that state — building toggles, accordions, and menus with no JavaScript at all.
/* Visually hide the checkbox but keep it operable */
.toggle {
position: absolute;
opacity: 0;
}
/* The panel starts collapsed */
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
/* When the box is checked, reveal the sibling panel */
.toggle:checked ~ .panel {
max-height: 400px;
}
/* Style the label when its checkbox is active */
.toggle:checked ~ .label {
background: #4CAF50;
color: #fff;
}
<input type="checkbox" id="t1" class="toggle">
<label for="t1" class="label">Details</label>
<div class="panel">
<p>Revealed when the checkbox is checked.</p>
</div>
⚠️ Keep it accessible
Hide the checkbox with opacity: 0 or clipping — never display: none, which removes it from the tab order and from screen readers. Always pair it with a real <label for> so keyboard and assistive-tech users can operate it. For complex widgets, a small amount of JavaScript with proper ARIA is often the more robust choice.
Best Practices
✅ Do
- Keep chains short —
.menu > lireads better thannav div ul li a span. - Use the child combinator to stop rules leaking into nested components.
- Reach for
* + *spacing instead of "margin on all, reset the first". - Comment any combinator chain whose intent isn't obvious at a glance.
⚠️ Don't
- Don't build deep descendant chains that shatter when markup changes.
- Don't rely on combinators for critical interactivity that assistive tech must understand — validate the checkbox hack with a keyboard.
- Don't over-worry about selector performance; browsers match right-to-left and are heavily optimized. Clarity beats micro-optimization until a profiler says otherwise.
💡 How the browser reads a selector
Given nav > ul > li, the engine starts at the rightmost part (li), finds every list item, then walks up checking the relationships. That's why an overly generic key selector like * on the right is the expensive part — not the length of the chain itself.
Hands-on Exercise
🏋️ Build a pure-CSS accordion
Objective: Create a three-item FAQ accordion that opens and closes with no JavaScript, using the checkbox hack and a sibling combinator.
Instructions:
- For each item: a hidden checkbox, a
<label>as the question, and a.answerpanel that follows. - Collapse the answer with
max-height: 0; overflow: hiddenand a transition. - Expand it with
input:checked ~ .answer. - Rotate a
+/−marker on the label using an adjacent-sibling or::afterrule tied to:checked. - Verify you can open each item with the keyboard alone (Tab to the label region, Space to toggle).
💡 Hint
Order matters for sibling combinators: the .answer must appear after the checkbox in the HTML, or ~ can never reach it. Put the checkbox first, then the label, then the answer panel.
✅ Sample solution
<div class="faq-item">
<input type="checkbox" id="q1" class="faq-toggle">
<label for="q1" class="faq-q">What is a combinator?</label>
<div class="faq-a"><p>The character between two selectors.</p></div>
</div>
.faq-toggle { position: absolute; opacity: 0; }
.faq-q {
display: block;
padding: 0.75rem 1rem;
cursor: pointer;
background: #f1f5f9;
}
.faq-q::after { content: " +"; float: right; }
.faq-a {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
/* The reveal, driven entirely by the sibling combinator */
.faq-toggle:checked ~ .faq-a { max-height: 300px; }
.faq-toggle:checked ~ .faq-q::after { content: " −"; }
Each item is self-contained, so you can stack as many as you like. No script ever runs.
🎯 Quick Quiz
Question 1: Which selector matches only paragraphs that are direct children of an <article>, ignoring paragraphs nested inside a wrapper div?
Question 2: What is the difference between h2 + p and h2 ~ p?
Question 3: In the checkbox hack, why must the panel appear after the checkbox in the HTML?
Summary & Quiz
🎉 Key Takeaways
- Descendant (space) reaches any depth; child (
>) reaches only direct children. - Adjacent sibling (
+) selects the very next sibling; general sibling (~) selects all later ones. - Sibling combinators only ever look forward — there is no previous-sibling selector.
- The
* + *pattern gives robust between-items spacing with no exceptions to maintain. - The checkbox hack pairs
:checkedwith~to build interactivity without JavaScript — just keep it accessible.
📚 Further Reading
🚀 What's Next?
Combinators let you reach real elements by relationship. Next we go one level further and style parts of elements that don't exist in the markup at all — the pseudo-elements ::before and ::after and the generated content they carry.
🎉 Nicely done!
You can now navigate the document tree with CSS alone. Time to conjure content from nothing.