Skip to content
AgileLogics
7 min read

Advanced Pseudo-Classes and :has()

Prerequisites: Pseudo-elements, Specificity in depth

Structural pseudo-classes

Selector Matches
:first-child / :last-child / :only-child By position among siblings
:nth-child(3) The 3rd child
:nth-child(odd) / :nth-child(2n) Odd / even children
:nth-child(3n + 1) 1st, 4th, 7th, ...
:nth-child(-n + 3) The first three
:nth-last-child(2) The second from the end
:nth-of-type(2) The 2nd sibling of the same element type
:nth-child(2 of .featured) The 2nd child that has the class featured
:empty No children (not even text)
:root The html element
<style>
  li:nth-child(-n + 2) { font-weight: bold; }
  li:nth-child(3n) { color: red; }
  li:last-child { font-style: italic; }
</style>
<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li></ul>
<script>
  for (const li of document.querySelectorAll("li")) {
    const s = getComputedStyle(li);
    console.log(li.textContent, s.fontWeight, s.color, s.fontStyle);
  }
</script>
1 700 rgb(0, 0, 0) normal
2 700 rgb(0, 0, 0) normal
3 400 rgb(255, 0, 0) normal
4 400 rgb(0, 0, 0) normal
5 400 rgb(0, 0, 0) normal
6 400 rgb(255, 0, 0) italic

Form state pseudo-classes

:checked, :disabled, :enabled, :required, :optional, :valid, :invalid, :user-valid, :user-invalid, :placeholder-shown, :in-range, :out-of-range, :read-only, :default, :indeterminate.

input:user-invalid { border-color: #c62828; }
input:required + .hint::after { content: " (required)"; }

:has() — the "parent selector"

:has() matches an element if something inside it (or after it) matches the argument. It finally lets CSS style parents and previous siblings based on what they contain:

.card:has(img) { grid-template-rows: 12rem auto; }

.field:has(input:user-invalid) label { color: #c62828; }

form:has(input[type="checkbox"]:not(:checked)) .submit { opacity: 0.5; }

h2:has(+ p) { margin-block-end: 0.25rem; }

body:has(dialog[open]) { overflow: hidden; }

.grid:has(> :nth-child(7)) { grid-template-columns: repeat(4, 1fr); }
  • h2:has(+ p) — an h2 immediately followed by a p (a "previous sibling" selector).
  • body:has(dialog[open]) — stop the page scrolling while a dialog is open.
  • .grid:has(> :nth-child(7)) — quantity queries: a different layout when the grid has seven or more items.
<style>
  .card { border: 1px solid gray; }
  .card:has(img) { border-color: blue; }
  .card:has(.badge-sale) { border-color: red; }
  label:has(+ input:required)::after { content: " *"; }
</style>
<div class="card" id="c1"><p>Text only</p></div>
<div class="card" id="c2"><img src="x.jpg" alt="" width="10" height="10"></div>
<div class="card" id="c3"><img src="y.jpg" alt="" width="10" height="10"><span class="badge-sale">Sale</span></div>
<label for="n">Name</label><input id="n" required>
<script>
  for (const id of ["c1", "c2", "c3"]) {
    console.log(id, getComputedStyle(document.getElementById(id)).borderTopColor);
  }
  console.log("label:", getComputedStyle(document.querySelector("label"), "::after").content);
</script>
c1 rgb(128, 128, 128)
c2 rgb(0, 0, 255)
c3 rgb(255, 0, 0)
label: " *"

:has() works in all major browsers (Chrome and Edge 105, Safari 15.4, Firefox 121). Its specificity is that of its most specific argument, and it can't be nested inside another :has().

Practice

Style a nav so that when it contains a link with aria-current="page", all the other links are dimmed to 70% opacity.

Show solution
nav:has(a[aria-current="page"]) a:not([aria-current="page"]) {
  opacity: 0.7;
}