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)— anh2immediately followed by ap(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;
}