DEV Community

Cover image for CSS Combinators: How to Write Half the CSS With Twice the Clarity
Muhammad Usman
Muhammad Usman

Posted on • Originally published at pixicstudio.Medium

CSS Combinators: How to Write Half the CSS With Twice the Clarity

The difference between messy CSS and elegant CSS isn’t what you think. It’s not about knowing the latest framework. It’s not about memorizing every property. It’s not even about understanding flexbox vs grid (though that helps). It’s about those tiny symbols (CSS Combinators) between your selectors. The space. The >. The +. The ~.

Most developers treat them like punctuation, just syntax that connects one selector to another. But they’re so much more than that. They’re relationships, structure, and the actual language of CSS.

Once you learn how to use CSS Combinators, you will start writing half the CSS with twice the clarity. And your HTML will stays clean as well.

Ignore them, and you’ll spend your entire frontend career drowning in utility classes and fighting !important declarations.

As always, here are the working examples of CSS Combinators:

So yeah, let’s talk about combinators.

What Are CSS Combinators?

CSS Combinators aren’t some advanced CSS feature. They’re literally just the symbols (or spaces) that connect your selectors, that’s it.

When you write div p, that space? That's a combinator. When you write article > h2, that > symbol? Also a combinator.

They define relationships between elements, parent-child, sibling-to-sibling, ancestor-descendant. Once you learn these combinators, you’ll write better CSS with fewer classes and less specificity mess.

The Four CSS Combinators You Need to Know

There are technically six combinators in the CSS spec, but only four matter right now. The other two (column combinator and namespace separator) are either experimental or super niche.

1. Descendant Combinator (The Space)

Syntax: A B

This is the one everyone uses without thinking about it:

article p {
  line-height: 1.8;
}
Enter fullscreen mode Exit fullscreen mode

It selects ALL p elements inside article, no matter how deeply nested. Grandchildren, great-grandchildren, doesn't matter.

Real-world example:

.blog-post p {
  margin-bottom: 1.5rem;
  color: #333;
}
Enter fullscreen mode Exit fullscreen mode
.blog-post h2 + p {
  font-size: 1.1em;
  color: #555;
}
Enter fullscreen mode Exit fullscreen mode

This gives all paragraphs in blog posts consistent spacing, but makes the first paragraph after any heading slightly bigger and lighter. Clean, semantic, no extra classes needed.

When you don’t care about nesting levels. Content areas, articles, basically anywhere your HTML structure isn’t guaranteed.

Performance note: People used to say descendant selectors were slow. They’re not, because modern browsers are insanely optimized for these. I’ve profiled this stuff, don’t worry about it.

2. Child Combinator (>)

Syntax: A > B

This is the “strict parent” selector. It only matches direct children:

.card > img {
  width: 100%;
  border-radius: 8px 8px 0 0;
}
Enter fullscreen mode Exit fullscreen mode

That img has to be a direct child of .card. If it's wrapped in a div, no match.

Here’s where this gets more creative:

.navigation > ul {
  display: flex;
  gap: 2rem;
}
Enter fullscreen mode Exit fullscreen mode
.navigation > ul > li {
  position: relative;
}
Enter fullscreen mode Exit fullscreen mode
.navigation > ul > li > a {
  color: white;
  padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Nested dropdowns don't get the same styles */
.navigation > ul > li > ul {
  position: absolute;
  top: 100%;
  background: #333;
}
Enter fullscreen mode Exit fullscreen mode
.navigation > ul > li > ul > li > a {
  padding: 0.5rem 1rem;
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

We have complete control over each level of the navigation without classes. The top level gets flex layout, dropdowns get absolute positioning, clean separation.

Component boundaries, when you want to prevent styles from leaking into nested structures, or when your HTML is predictable.

3. Next-Sibling Combinator (+)

Syntax: A + B

This selects an element that immediately follows another. Same parent, right after.

h2 + p {
  font-size: 1.2em;
  font-weight: 500;
}
Enter fullscreen mode Exit fullscreen mode

Only the paragraph directly after h2 gets styled. Perfect for lead paragraphs.

But here’s my favorite use case (and this is some pro-level and creative stuff):

/* The "lobotomized owl" - adds spacing between elements */
.stack > * + * {
  margin-top: 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode

This adds top margin to every element except the first one. No matter what elements you throw in there. Headings, paragraphs, images, whatever. Consistent spacing, zero extra classes.

Another killer pattern:

/* Remove margin between specific heading combinations */
h2 + h3,
h3 + h4 {
  margin-top: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* But keep normal spacing for other elements */
* + h2,
* + h3,
* + h4 {
  margin-top: 2rem;
}
Enter fullscreen mode Exit fullscreen mode

Now your headings have tight spacing when stacked, but normal spacing otherwise.

When to use it?
Spacing patterns, typography, anywhere the “next thing” is special.

4. General Sibling Combinator (~)

Syntax: A ~ B

This selects ALL siblings that come after an element:

h2 ~ p {
  color: #666;
}
Enter fullscreen mode Exit fullscreen mode

Every p after an h2 (at the same level) gets styled.

Here’s where this actually shines:

/* Style all content after a "read more" toggle */
.article .toggle:checked ~ .full-content {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode
.article .toggle:checked ~ .preview {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

One checkbox, and you can show/hide entire sections of content. No JavaScript needed.

Form validation pattern I use constantly:

input:invalid:not(:placeholder-shown) ~ .error-message {
  display: block;
  color: #d32f2f;
}
Enter fullscreen mode Exit fullscreen mode
input:valid:not(:placeholder-shown) ~ .success-message {
  display: block;
  color: #4caf50;
}
Enter fullscreen mode Exit fullscreen mode

The form tells users what’s wrong as they type. The ~ combinator makes this possible because the error message comes after the input in the HTML.

:has() Solves the Parent Selector Problem

Technically not a combinator, but it works WITH combinators and it’s pretty amazing. It’s the parent selector we’ve been begging for since 2005.

/* Select articles that contain an h2 */
article:has(h2) {
  border-left: 4px solid blue;
}
Enter fullscreen mode Exit fullscreen mode
/* Select labels whose input is invalid */
label:has(+ input:invalid) {
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

Browser support is excellent now (Safari 15.4+, Chrome 105+). Firefox is getting there.

Real-world example:

/* Different card layouts based on content */
.card:has(img) {
  display: grid;
  grid-template-columns: 200px 1fr;
}
Enter fullscreen mode Exit fullscreen mode
.card:not(:has(img)) {
  padding: 2rem;
  border: 2px solid #e0e0e0;
}
Enter fullscreen mode Exit fullscreen mode
/* Forms with errors get highlighted */
.form-group:has(input:invalid) {
  background: #ffebee;
  border-left: 4px solid #d32f2f;
}
Enter fullscreen mode Exit fullscreen mode

No JavaScript, no extra classes, just smart CSS.

Specificity: The Part Everyone Gets Wrong

Here’s what you need to know: combinators add ZERO specificity.

That space in div p? Zero specificity. The > in div > p? Also zero.

Only the selectors matter:

/* Specificity: 0-0-2 (two elements) */
article p { }
Enter fullscreen mode Exit fullscreen mode
/* Also 0-0-2 (combinator doesn't count) */
article > p { }
Enter fullscreen mode Exit fullscreen mode
/* 0-0-1-1 (one class, one element) */
.post p { }
Enter fullscreen mode Exit fullscreen mode

This catches people all the time:

/* Both have same specificity */
article p { color: blue; }
article > p { color: red; }
Enter fullscreen mode Exit fullscreen mode
/* Red wins because it's last */
Enter fullscreen mode Exit fullscreen mode

If you want the second one to win, increase its specificity:

article p { color: blue; }
article.featured > p { color: red; }
/* Now red wins (0-0-1-2 beats 0-0-0-2) */
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns From My Production Code

The Card Component

This is how I build cards now. Notice how structure drives styling:

.card {
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  overflow: hidden;
}
Enter fullscreen mode Exit fullscreen mode
/* Full-width images at the top */
.card > img:first-child {
  width: 100%;
  height: 200px;
  object-fit: cover;
}
Enter fullscreen mode Exit fullscreen mode
/* Content area */
.card > .content {
  padding: 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* First element in content (usually h3) has no top margin */
.card > .content > :first-child {
  margin-top: 0;
}
Enter fullscreen mode Exit fullscreen mode
/* All paragraphs get consistent spacing */
.card > .content p {
  color: #666;
  line-height: 1.6;
}
Enter fullscreen mode Exit fullscreen mode
/* Last element has no bottom margin */
.card > .content > :last-child {
  margin-bottom: 0;
}
Enter fullscreen mode Exit fullscreen mode
/* Footer area */
.card > .footer {
  padding: 1rem 1.5rem;
  background: #f8f9fa;
  border-top: 1px solid #e0e0e0;
}
Enter fullscreen mode Exit fullscreen mode
/* Buttons in footer are auto-styled */
.card > .footer button {
  padding: 0.5rem 1rem;
  background: #2196f3;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode
/* Cards with images get tighter content padding */
.card:has(> img) > .content {
  padding: 1rem 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode

Clean. Predictable. No class soup.

Article Typography System

This is my go-to for blog posts and long-form content:

article {
  max-width: 70ch;
  margin: 0 auto;
  padding: 2rem;
  font-size: 1.125rem;
  line-height: 1.7;
}
Enter fullscreen mode Exit fullscreen mode
/* All paragraphs */
article p {
  margin-bottom: 1.5rem;
  color: #333;
}
Enter fullscreen mode Exit fullscreen mode
/* Lead paragraph (first after any heading) */
article h1 + p,
article h2 + p,
article h3 + p {
  font-size: 1.25em;
  color: #555;
  line-height: 1.6;
}
Enter fullscreen mode Exit fullscreen mode
/* Blockquotes */
article blockquote {
  border-left: 4px solid #2196f3;
  padding-left: 1.5rem;
  margin: 2rem 0;
  font-style: italic;
  color: #555;
}
Enter fullscreen mode Exit fullscreen mode
/* Paragraphs after blockquotes get extra space */
article blockquote + p {
  margin-top: 2rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Lists */
article ul,
article ol {
  margin-bottom: 1.5rem;
  padding-left: 2rem;
}
Enter fullscreen mode Exit fullscreen mode
/* List items get spacing too */
article li + li {
  margin-top: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Code blocks */
article pre {
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 1.5rem;
  border-radius: 8px;
  overflow-x: auto;
  margin: 2rem 0;
}
Enter fullscreen mode Exit fullscreen mode
/* Inline code in paragraphs */
article p code {
  background: #f5f5f5;
  padding: 0.2em 0.4em;
  border-radius: 4px;
  font-size: 0.9em;
  color: #e53935;
}
Enter fullscreen mode Exit fullscreen mode
/* Images and figures */
article figure {
  margin: 3rem 0;
}
Enter fullscreen mode Exit fullscreen mode
article figure > img {
  width: 100%;
  border-radius: 8px;
}
Enter fullscreen mode Exit fullscreen mode
article figure > figcaption {
  margin-top: 0.75rem;
  text-align: center;
  font-size: 0.9em;
  color: #666;
  font-style: italic;
}
Enter fullscreen mode Exit fullscreen mode
/* Heading stack optimization */
article h2 + h3,
article h3 + h4 {
  margin-top: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode

This gives you beautiful typography with zero classes in your HTML. Just write semantic markup and it looks great.

Form Validation That Doesn’t Suck

.form-group {
  margin-bottom: 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Labels */
.form-group > label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
  color: #333;
  transition: color 0.3s;
}
Enter fullscreen mode Exit fullscreen mode
/* Show required asterisk */
.form-group > label:has(+ :required)::after {
  content: " *";
  color: #d32f2f;
}
Enter fullscreen mode Exit fullscreen mode
/* Input fields */
.form-group > input,
.form-group > textarea {
  width: 100%;
  padding: 0.75rem;
  border: 2px solid #e0e0e0;
  border-radius: 6px;
  font-size: 1rem;
  transition: border-color 0.3s;
}
Enter fullscreen mode Exit fullscreen mode
/* Focus state */
.form-group > input:focus,
.form-group > textarea:focus {
  outline: none;
  border-color: #2196f3;
}
Enter fullscreen mode Exit fullscreen mode
/* Invalid state (only show when user has typed) */
.form-group > input:invalid:not(:placeholder-shown) {
  border-color: #d32f2f;
}
Enter fullscreen mode Exit fullscreen mode
/* Valid state */
.form-group > input:valid:not(:placeholder-shown) {
  border-color: #4caf50;
}
Enter fullscreen mode Exit fullscreen mode
/* Label changes color based on input state */
.form-group:has(input:invalid:not(:placeholder-shown)) > label {
  color: #d32f2f;
}
Enter fullscreen mode Exit fullscreen mode
.form-group:has(input:valid:not(:placeholder-shown)) > label {
  color: #4caf50;
}
Enter fullscreen mode Exit fullscreen mode
/* Error messages */
.form-group > .error {
  display: none;
  color: #d32f2f;
  font-size: 0.875rem;
  margin-top: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Show error when invalid */
.form-group:has(input:invalid:not(:placeholder-shown)) > .error {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode
/* Help text */
.form-group > .help-text {
  font-size: 0.875rem;
  color: #666;
  margin-top: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode
/* Hide help text when there's an error */
.form-group:has(input:invalid:not(:placeholder-shown)) > .help-text {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Users get real-time feedback. Labels change color. Error messages show up. All CSS, no JavaScript.

Navigation With Dropdowns

.nav {
  background: #2c3e50;
}
Enter fullscreen mode Exit fullscreen mode
/* Main list */
.nav > ul {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode
/* Top level items */
.nav > ul > li {
  position: relative;
}
Enter fullscreen mode Exit fullscreen mode
/* Top level links */
.nav > ul > li > a {
  display: block;
  padding: 1rem 1.5rem;
  color: white;
  text-decoration: none;
  transition: background 0.2s;
}
Enter fullscreen mode Exit fullscreen mode
.nav > ul > li > a:hover {
  background: rgba(255, 255, 255, 0.1);
}
Enter fullscreen mode Exit fullscreen mode
/* Add dropdown arrow */
.nav > ul > li:has(> ul) > a::after {
  content: " ▾";
  font-size: 0.8em;
}
Enter fullscreen mode Exit fullscreen mode
/* Dropdown menu */
.nav > ul > li > ul {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 220px;
  background: #34495e;
  list-style: none;
  margin: 0;
  padding: 0;
  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
Enter fullscreen mode Exit fullscreen mode
/* Show on hover */
.nav > ul > li:hover > ul {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode
/* Dropdown links */
.nav > ul > li > ul > li > a {
  display: block;
  padding: 0.75rem 1.5rem;
  color: white;
  text-decoration: none;
  transition: background 0.2s;
}
Enter fullscreen mode Exit fullscreen mode
.nav > ul > li > ul > li > a:hover {
  background: rgba(255, 255, 255, 0.15);
}
Enter fullscreen mode Exit fullscreen mode
/* Dividers between dropdown items */
.nav > ul > li > ul > li + li {
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}
Enter fullscreen mode Exit fullscreen mode
/* Active page indicator */
.nav a[aria-current="page"] {
  background: rgba(255, 255, 255, 0.2);
  font-weight: 600;
}
Enter fullscreen mode Exit fullscreen mode

Fully functional dropdown navigation. Hover states, active indicators, the works. Structure is styling.

Common Mistakes (And How I’ve Learned From Them)

Mistake 1: Chaining Too Deep

/* Bad - this will break when HTML changes */
body > div > main > section > article > div > p {
  color: blue;
}
Enter fullscreen mode Exit fullscreen mode
/* Good - resilient and clear */
.article-text {
  color: blue;
}
Enter fullscreen mode Exit fullscreen mode

I used to do this. Don’t be like young me.

Mistake 2: Not Understanding Sibling Direction

/* This only affects elements AFTER h2 */
h2 ~ p {
  color: gray;
}
Enter fullscreen mode Exit fullscreen mode

Siblings only go forward, never backward. This confused me for months when I started.

Mistake 3: Overusing Descendant When Child Would Work

/* Overkill - affects nested lists too */
.menu ul {
  list-style: none;
}
Enter fullscreen mode Exit fullscreen mode
/* Better - only direct children */
.menu > ul {
  list-style: none;
}
Enter fullscreen mode Exit fullscreen mode

Be as specific as needed, not more.

Mistake 4: Fighting Specificity

/* You'll lose this fight */
div div div p {
  color: blue;
}
Enter fullscreen mode Exit fullscreen mode
/* Then you'll do this (don't) */
div div div div p {
  color: red !important;
}
Enter fullscreen mode Exit fullscreen mode
/* Just do this instead */
.text-primary {
  color: blue;
}
Enter fullscreen mode Exit fullscreen mode
.text-secondary {
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

Specificity wars are unwinnable. Keep selectors simple.

Performance

People still ask me if descendant selectors are slow. The answer is: not anymore.

Modern browsers use bloom filters, ancestor caching, and fast-path optimizations. I’ve profiled production apps with thousands of selectors. Style recalculation is rarely the bottleneck.

Focus on:

  • Keeping your stylesheet under 100KB
  • Avoiding unnecessary reflows
  • Writing maintainable code

Don’t worry about:

  • Descendant vs child combinator speed
  • Number of combinators in a selector
  • Selector matching performance

Unless you’re building something truly massive, it won’t matter.

When to Use Classes Instead

Combinators are great, but they’re not always the answer:

Use classes when:

  • The element appears in multiple contexts
  • You need maximum reusability
  • The HTML structure is unpredictable
  • You’re building a component library

Use combinators when:

  • Structure is stable and meaningful
  • You don’t control the HTML (CMS content)
  • You want to reduce HTML clutter
  • The relationship is semantically important

I usually use a mix. My components have classes. My content areas use combinators.

The Modern Approach

Here’s how I think about CSS architecture now:

  1. Components get classes: .button.card.modal
  2. Component internals use combinators: .card > img.modal > .header
  3. Content areas use combinators: article p, article h2 + p
  4. Layout uses classes: .grid.flex.container
  5. State uses pseudo-classes and :has(): .card:hover.form:has(:invalid)

This gives you the best of both worlds. Reusable components with clean, semantic internals.

Browser Support

All four main combinators work everywhere. Even IE11 (if you still care).

:has() is the new kid:

  • Safari 15.4+
  • Chrome 105+
  • Edge 105+
  • Firefox 103+ (behind flag, coming to stable soon)

For production use today? I’d add a fallback:

/* Works everywhere */
.card.has-image {
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode
/* Enhanced for modern browsers */
.card:has(> img) {
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

After writing CSS for more than half a decade, CSS Combinators are still my favorite feature. They’re simple, powerful, and they make your code better.

Master these four patterns:

  1. Descendant for content areas
  2. Child for component boundaries
  3. Next-sibling for spacing
  4. General sibling for state

Add :has() to the mix, and you can build almost anything without JavaScript.

The key is understanding relationships. CSS isn’t just about making things pretty. It’s about expressing structure, hierarchy, and meaning. Combinators are how you do that.

Stop fighting specificity. Stop adding classes to everything. Learn the combinators, understand the relationships, and let your HTML structure do the work.

Your future self will thank you.

Quick Reference:

  • A B - Any B inside A (descendant)
  • A > B - B directly inside A (child)
  • A + B - B immediately after A (next-sibling)
  • A ~ B - Any B after A (general sibling)
  • A:has(B) - A that contains B (relational)

Now go write some better CSS.

— — — — — — — — — — — — — — — — — — — — — — —

Did you learn something good today?
Then show some love.
©Usman Writes
WordPress Developer | Website Strategist | SEO Specialist
Don’t forget to subscribe to Developer’s Journey to show your support.

Top comments (0)