Skip to main content

🔗 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

graph TD A["CSS combinators"] --> B["Descendant — space"] A --> C["Child — >"] A --> D["Adjacent sibling — +"] A --> E["General sibling — ~"] B --> B1["article p"] C --> C1["article > p"] D --> D1["h2 + p"] E --> E1["h2 ~ p"]
CombinatorSymbolRelationshipExample
DescendantspaceNested at any deptharticle p
Child>Direct child onlyarticle > p
Adjacent sibling+The very next siblingh2 + p
General sibling~Any later siblingh2 ~ 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 versus child combinator An article contains a direct paragraph and a div; the div contains a nested paragraph. The descendant selector matches both paragraphs; the child selector matches only the direct one. article p — direct child  ·  matched by article p AND article > p div.content p — nested one level deeper matched by article p only — NOT by article > p
Figure 1 — The descendant combinator ignores depth; the child combinator stops at one level. Choose child when you deliberately want to not reach into nested wrappers.
Descendant (space)Child (>)
Matches at any nesting levelMatches direct children only
Resilient when you add wrapper markupPrecise, but breaks if you wrap the target
Great for broad content areasGreat 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 siblingAll following siblings
Precise, positionalBroad, group-wide
h2 + p — the one lead paragraphh2 ~ 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>
flowchart LR A["User clicks <label>"] --> B["Linked checkbox toggles :checked"] B --> C[".toggle:checked ~ .panel"] C --> D["Panel expands — no JS"]

⚠️ 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 > li reads better than nav 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:

  1. For each item: a hidden checkbox, a <label> as the question, and a .answer panel that follows.
  2. Collapse the answer with max-height: 0; overflow: hidden and a transition.
  3. Expand it with input:checked ~ .answer.
  4. Rotate a +/ marker on the label using an adjacent-sibling or ::after rule tied to :checked.
  5. 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 :checked with ~ 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.