Modern CSS selectors you will actually use: :is(), :where(), and :has(). The goal is shorter, clearer selectors and styling based on document structure without sprinkling extra classes on every parent.
:is() – grouping without repetition
:is() accepts a selector list and matches if any branch matches. It collapses long comma-separated lists:
/* Before */
article h1,
article h2,
article h3 {
line-height: 1.2;
}
/* After */
article :is(h1, h2, h3) {
line-height: 1.2;
}
Specificity is the highest-specificity argument inside :is(). So :is(.nav, #menu) a counts as #menu a specificity (ID wins).
:where() – grouping with zero specificity
:where() works like :is() but contributes zero specificity. Use it for base styles you expect to override:
:where(h1, h2, h3, h4) {
font-weight: 600;
line-height: 1.25;
}
.card :where(h2) {
font-size: 1.25rem;
margin-block: 0 0.5rem;
}
A single utility class like .text-light overrides these without a fight. That is the point.
:has() – the parent selector
:has() selects an element if it contains something matching the argument. Finally, you can style a card because it contains an image, or a form row because an input is invalid:
/* Card with a thumbnail gets a horizontal layout */
.card:has(.card__media) {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
}
/* Highlight a nav item that contains the current page link */
.nav li:has([aria-current="page"]) {
font-weight: 600;
}
/* Style the label when its input is required */
label:has(+ input[required])::after {
content: " (required)";
font-weight: normal;
color: var(--color-muted, #6b7280);
}
:has() is powerful and easy to overuse. If a condition is stable and semantic, a class on the parent is still clearer for the next reader. Reach for :has() when the DOM shape already expresses the condition and adding a class would be boilerplate.
Form patterns with :has()
Invalid and valid states often live on inputs. With :has(), the surrounding field wrapper can react:
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" required>
</div>
.field:has(input:user-invalid) {
border-inline-start: 3px solid #dc2626;
padding-inline-start: 0.75rem;
}
.field:has(input:user-valid:not(:placeholder-shown)) {
border-inline-start: 3px solid #16a34a;
}
:user-invalid and :user-valid fire after interaction, which avoids screaming red borders on every field before the user has typed. We cover form states in more detail later in these tutorials.
:not() got smarter
:not() now accepts complex selectors, not just simple ones:
/* Style all direct children except the last two */
.grid > :not(:nth-last-child(-n+2)) {
margin-block-end: 1rem;
}
/* Links that are not inside a heading */
a:not(:is(h1, h2, h3) a) {
text-decoration: underline;
}
Combining selectors
Real selectors stack these tools:
/* Sidebar section that contains a list but no heading */
aside :where(section):has(ul):not(:has(:is(h2, h3))) {
padding: 1rem;
background: #f9fafb;
}
If you write something this dense, add a comment explaining the intent. Future you will thank present you.
Performance and support
:has() can be expensive on large documents if the browser must re-evaluate wide subtrees on every change. For typical UI components it is fine. Test on target browsers; support is solid in current Chrome, Firefox, and Safari.
When support is uncertain, treat :has() as progressive enhancement: the page works without it, it just looks plainer.
The next tutorial introduces container queries – components that respond to their parent’s size, not the viewport.

