Selectors Deep Dive
Master advanced CSS selectors including combinators, pseudo-classes like :has() and :is(), attribute selectors, specificity rules, and native CSS nesting.
Combinators
Combinators define relationships between selectors. They let you target elements based on their position relative to other elements in the DOM tree.
/* Descendant (space) — any nested depth */ article p { color: #ccc; } /* Matches: <article><div><p> (any depth) */ /* Child (>) — direct children only */ ul > li { list-style: none; } /* Matches: <ul><li> but NOT <ul><ol><li> */ /* Adjacent sibling (+) — immediately next */ h2 + p { margin-top: 0; } /* Matches the first <p> right after <h2> */ /* General sibling (~) — any following sibling */ h2 ~ p { font-size: 0.95rem; } /* Matches ALL <p> siblings after the <h2> */
Child (>) vs Descendant (space):
>)>)
>)Adjacent (+) vs General (~) sibling:
+ and ~ (green, bold)~ (italic)~ (italic)Attribute Selectors
Target elements based on their HTML attributes and attribute values. Extremely powerful for styling links, form elements, and data-attribute driven UIs.
/* Has the attribute (any value) */ [title] { cursor: help; } /* Exact match */ [type="email"] { border-color: #667eea; } /* Starts with (^=) */ [href^="https"] { color: #43e97b; } /* Ends with ($=) */ [href$=".pdf"] { color: #f5576c; } /* Contains (*=) */ [class*="btn"] { cursor: pointer; } /* Case-insensitive flag (i) */ [href$=".PDF" i] { color: #f5576c; } /* Whitespace-separated word match (~=) */ [class~="active"] { font-weight: bold; } /* Starts with value or value- (|=) for lang attributes */ [lang|="en"] { quotes: "\201C" "\201D"; }
:is() — Matches-Any Pseudo-class
:is() takes a comma-separated list of selectors and matches if any of them match. It greatly reduces repetition. Its specificity equals the most specific selector in the list.
/* Without :is() — repetitive */ header a:hover, nav a:hover, footer a:hover { color: #667eea; } /* With :is() — clean! */ :is(header, nav, footer) a:hover { color: #667eea; } /* Works nested too */ article :is(h1, h2, h3) { font-weight: 700; line-height: 1.2; } /* Combining with other selectors */ .card :is(img, video, picture) { border-radius: 8px; width: 100%; }
Heading 3
This paragraph follows h3 and gets styled via adjacent sibling.
Heading 4
Same style applied to all heading levels using :is(h3, h4, h5).
Heading 5
One selector rule styles all three heading levels with gradient text.
:where() — Zero Specificity
:where() works exactly like :is() but its specificity is always zero. This makes it perfect for base styles that are easy to override.
/* :where() has zero specificity — easily overridden */ :where(article, section, aside) p { line-height: 1.7; color: #b0b0c0; } /* This single class easily overrides the :where rule */ .highlight { color: #f5576c; /* wins because :where = 0 specificity */ } /* Great for CSS resets */ :where(h1, h2, h3, h4, h5, h6) { margin: 0; font-size: inherit; font-weight: inherit; } /* vs :is() comparison */ :is(article, section) p { } /* specificity: 0-0-2 (element + element) */ :where(article, section) p { } /* specificity: 0-0-1 (only p counts) */
This uses :where() styling — gray text (zero specificity).
This has a simple class override — green text wins easily because :where() = 0 specificity.
Use :is() for your application styles where normal specificity is desired. Use :where() for utility/reset/library styles that consumers should easily override.
:has() — The Parent Selector
The long-awaited "parent selector." :has() selects an element if it contains a descendant matching the argument. It can also check for adjacent siblings, states, and more.
/* Card with image gets different layout */ .card:has(img) { grid-template-rows: auto 1fr; } /* Card without image */ .card:not(:has(img)) { padding: 2rem; } /* Form validation — label color based on input state */ label:has(+ input:invalid) { color: #f5576c; } label:has(+ input:valid) { color: #43e97b; } /* Style parent based on child hover */ .nav:has(a:hover) { background: rgba(102,126,234,0.1); } /* Page has a specific element */ body:has(.modal.open) { overflow: hidden; }
Card With Image
:has(img) applies grid-template-rows for a structured layout.
Card Without Image
:not(:has(img)) gives this card extra padding and a gradient background instead.
Type in the fields — labels change color based on validity using :has(input:valid) and :has(input:invalid).
:not() — Negation Pseudo-class
Matches elements that do not match the given selector. The modern version accepts complex selector lists.
/* All paragraphs except .intro */ p:not(.intro) { font-size: 0.95rem; } /* All links except those with .no-style */ a:not(.no-style) { text-decoration: underline; color: #667eea; } /* All inputs except submit and reset */ input:not([type="submit"]):not([type="reset"]) { border: 1px solid #ccc; padding: 0.5rem; } /* Modern: comma-separated list in :not() */ :not(h1, h2, h3, h4, h5, h6) { /* matches everything except headings */ } /* Last child without border */ li:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.1); }
- Active item (hoverable)
- Another active item
- Disabled item (no hover, line-through via :not)
- Active item (no bottom border via :not(:last-child))
Compound Selectors
Combine multiple selectors without whitespace to require all conditions on the same element.
/* Element + class */ a.primary { color: #667eea; } /* Element + attribute */ input[required] { border-left: 3px solid #f5576c; } /* Multiple classes */ .btn.primary.large { font-size: 1.2rem; } /* Class + pseudo-class */ .card:hover:not(.disabled) { transform: translateY(-4px); } /* Element + multiple pseudo-classes */ li:first-child:last-child { /* only child (when there's exactly one li) */ border-radius: 8px; }
Specificity Calculation
Specificity determines which CSS rule wins when multiple rules target the same element. It is calculated as a three-part value: (ID, CLASS, ELEMENT).
/* Specificity: (ID - CLASS - ELEMENT) */ p /* 0-0-1 */ .card /* 0-1-0 */ #hero /* 1-0-0 */ p.intro /* 0-1-1 */ #hero .title /* 1-1-0 */ div#hero p.intro /* 1-1-2 */ .card .title a:hover /* 0-2-1 (:hover = class-level) */ [type="email"] /* 0-1-0 (attributes = class-level) */ ::before /* 0-0-1 (pseudo-elements = element-level) */ /* :is() takes the highest specificity in its list */ :is(.card, #hero) p /* 1-0-1 (#hero is highest) */ /* :where() is always zero */ :where(.card, #hero) p /* 0-0-1 (:where = 0 specificity) */ /* :not() and :has() use the specificity of their argument */ p:not(.intro) /* 0-1-1 (.intro contributes class-level) */ .card:has(img) /* 0-1-1 */
| Selector | Specificity | Level |
|---|---|---|
| * | 0-0-0 | Universal |
| p | 0-0-1 | Element |
| .card | 0-1-0 | Class |
| [type="email"] | 0-1-0 | Attribute |
| .card .title | 0-2-0 | Two classes |
| #hero | 1-0-0 | ID |
| #hero .title p | 1-1-1 | ID + class + element |
| style="" | Inline | Beats all selectors |
Selector Performance
Browsers read selectors right to left. While selector performance rarely matters in practice, understanding it helps you write better CSS.
/* SLOWER: browser finds ALL p, then checks ancestors */ body div.container ul li a p { } /* FASTER: browser finds .card-title directly */ .card-title { } /* Performance ranking (fastest to slowest): 1. ID #hero 2. Class .card 3. Element p 4. Attribute [type] 5. Universal * 6. Combinators .a .b > .c */
Modern browsers are incredibly fast at matching selectors. A page with thousands of elements and hundreds of rules still resolves in milliseconds. Write readable, maintainable selectors first. Only optimize if profiling reveals a real bottleneck.
Native CSS Nesting (&)
CSS now supports nesting natively (no preprocessor needed). The & symbol represents the parent selector, similar to Sass/SCSS.
/* Native CSS Nesting */ .card { padding: 1.5rem; border-radius: 12px; /* Nested child selector */ & .title { font-size: 1.2rem; font-weight: 700; } /* Nested pseudo-class */ &:hover { transform: translateY(-4px); } /* Nested pseudo-element */ &::after { content: ""; } /* & at the end — parent becomes qualifier */ .dark-theme & { background: #1a1a2e; /* Compiles to: .dark-theme .card */ } /* Nested media query */ @media (width < 768px) { padding: 1rem; } } /* Deeply nested */ .nav { & .menu { & .item { &:hover { color: #667eea; /* Result: .nav .menu .item:hover */ } } } }
Avoid nesting more than 3 levels deep. Deep nesting creates high-specificity selectors and makes CSS harder to maintain. Think of nesting as a convenience, not a requirement for every rule.
For element type selectors, you must use &: write & p { } not p { } inside a nested context. For class, ID, and pseudo-selectors, & is implied: &:hover and &.active work as expected.
Gotchas
:is() and :where() are forgiving selector lists — if one selector is invalid, the rest still work. However, :has() involving complex selectors can be performance-intensive on large DOMs. Avoid *:has() on the universal selector.
:is(.card, #hero) takes the specificity of #hero (1-0-0) for the entire selector. If you just wanted class-level specificity, use :where() instead or separate the selectors.
You cannot nest :has() inside another :has(). .a:has(.b:has(.c)) is invalid. You can, however, use :has() with other pseudo-classes: .card:has(img):not(.featured) works fine.
Native CSS nesting produces the same selectors as writing them flat. .a { & .b { & .c { } } } creates .a .b .c with specificity 0-3-0. Deep nesting means high specificity, making overrides difficult.
Pro Tips
Use :has() to style parent elements based on child state without JavaScript: form:has(:invalid) .submit { opacity: 0.5; } disables the submit button appearance when any field is invalid.
Use [class^="icon-"] or [class*=" icon-"] to apply base icon styles to any element with an icon class, regardless of which specific icon it is.
If you are building a component library, wrap your selectors in :where() so consumers can override styles with a single class. This is what modern CSS resets (like Josh Comeau's) use.
If you need to override high-specificity rules without !important, you can stack :is() or repeat a class: .card.card has specificity 0-2-0 and beats 0-1-0 without resorting to IDs or inline styles.
:has() is not limited to descendants. .input:has(+ .error) selects an input that has an adjacent sibling with class .error. This lets you style elements based on what comes after them.
Browser Support
Modern selector support across major browsers (as of 2025).
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| :is() | 88+ | 78+ | 14+ | 88+ |
| :where() | 88+ | 78+ | 14+ | 88+ |
| :has() | 105+ | 121+ | 15.4+ | 105+ |
| :not() (multi-arg) | 88+ | 84+ | 9+ | 88+ |
| CSS Nesting (&) | 120+ | 117+ | 17.2+ | 120+ |
| [attr i] (case-insensitive) | 49+ | 47+ | 9+ | 79+ |
| Attribute selectors | 1+ | 1+ | 3+ | 12+ |
| Combinators (>, +, ~) | 1+ | 1+ | 1+ | 12+ |