Home / Articles / Modern CSS /

modern-csshas

: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.