Home / Articles / Modern CSS /
:has() for sibling conditions
CSS can style elements based on what comes before them using + and ~. But styling based on what comes after? That required :has().
The sibling problem CSS couldn't solve
The adjacent sibling combinator (+) and general sibling combinator (~) only look forward in the DOM. If element A comes before element B, you can style B based on A — but never A based on B. This is a fundamental limitation of the CSS cascade that :has() removes.
/* Traditional: style the element AFTER */
h2 + p {
font-size: 1.1rem; /* works fine */
}
/* Impossible before :has() — style h2 based on what follows */
/* Now possible: */
h2:has(+ p) {
margin-bottom: 0.5rem;
}
h2:not(:has(+ p)) {
margin-bottom: 1.5rem;
}
The pattern h2:has(+ p) reads as "select any h2 that is immediately followed by a p." This is a previous-sibling selector — the missing piece from the CSS combinator set.
Adjacent vs. general sibling inside :has()
Both + and ~ work inside :has(), giving you fine-grained control over which sibling relationship to match.
/* Immediately followed by — adjacent sibling */
.label:has(+ .input:focus) {
color: oklch(0.72 0.19 265);
transform: translateY(-2px);
}
/* Followed anywhere by — general sibling */
.step:has(~ .step.active) {
/* This step comes BEFORE the active one */
opacity: 0.6;
border-left-color: oklch(0.7 0.18 145);
}
/* This step comes AFTER the active one */
.step.active ~ .step {
opacity: 0.4;
border-left-color: oklch(0.4 0.02 260);
}
Combining :has(~ .active) with .active ~ .step gives you three distinct states: before-active, active, and after-active — all in pure CSS.
Spacing and typography with sibling :has()
One of the most practical uses of sibling :has() is contextual spacing. The margin below a heading should depend on what follows it.
/* Tight spacing when a subtitle follows a title */
h1:has(+ .subtitle) {
margin-bottom: 0.25rem;
}
/* Normal spacing when body text follows */
h1:has(+ p) {
margin-bottom: 1rem;
}
/* Extra spacing when a code block follows */
h2:has(+ .code-block) {
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid oklch(0.3 0.02 260);
}
/* Remove bottom border on last heading before a section break */
h2:has(+ hr) {
border-bottom: none;
margin-bottom: 0;
}
This replaces the "lobotomized owl" pattern (* + *) with precise, context-aware spacing rules that respond to actual content adjacency.
Navigation and list patterns
Navigation items often need different styles based on their position relative to the active item. With sibling :has(), you can style items before the active one — something previously requiring JavaScript or nth-child math.
/* Style the breadcrumb separator before the current page */
.breadcrumb li:has(+ li[aria-current="page"])::after {
color: oklch(0.72 0.19 265);
}
/* Tab before the active tab gets a right border */
.tab:has(+ .tab[aria-selected="true"]) {
border-right-color: oklch(0.72 0.19 265);
}
/* Active tab's left neighbor gets no right-border-radius */
.tab:has(+ .tab[aria-selected="true"]) {
border-top-right-radius: 0;
}
/* Active tab's right neighbor gets no left-border-radius */
.tab[aria-selected="true"] + .tab {
border-top-left-radius: 0;
}
Form field chains
Forms often group fields that affect each other visually. Sibling :has() lets you style a label or wrapper based on the state of the input that follows it.
/* Float the label up when the next-sibling input has focus */
label:has(+ input:focus),
label:has(+ input:not(:placeholder-shown)) {
transform: translateY(-1.5rem) scale(0.85);
color: oklch(0.72 0.19 265);
}
/* Connect adjacent fields visually when both are filled */
.field:has(+ .field input:not(:placeholder-shown))
input:not(:placeholder-shown) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.field:has(input:not(:placeholder-shown)) + .field
input:not(:placeholder-shown) {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 1px solid oklch(0.3 0.02 260);
}
The floating label pattern traditionally required JavaScript focus/blur listeners. With :has(+ input:focus), the label lifts itself when the following input gains focus.
Performance and specificity
Sibling :has() is generally faster than descendant :has() because the browser only needs to check siblings — not an entire subtree. The specificity of :has() equals the specificity of its most specific argument.
/* Specificity: (0, 1, 1) — same as .label + input:focus */
.label:has(+ input:focus) {
color: oklch(0.72 0.19 265);
}
/* Use :where() inside :has() to zero out specificity */
.label:has(+ :where(input:focus)) {
color: oklch(0.72 0.19 265);
/* Specificity: (0, 1, 0) — just .label */
}
/* Combine with @layer for full control */
@layer components {
.step:has(~ .step.active) {
opacity: 0.6;
}
}
If you find your sibling :has() rules conflicting with other styles, wrap them in :where() or use @layer to manage the cascade cleanly.