Advanced Selectors
🎯 Chapter 5 — Advanced Selectors
Basic selectors (element, class, id) get you 80% of the way. The remaining 20% — fine-grained control without adding extra classes — comes from combinators, attribute selectors, and structural pseudo-classes. Master these and you will write CSS that reacts to the structure of your HTML: "style every other list item," "target links that go to PDFs," "select the last card in a row." No JavaScript required.
1 — Combinators
A combinator is a character that sits between two simple selectors and describes the relationship between the elements they match. There are four.
| Combinator | Symbol | Meaning |
|---|---|---|
| Descendant | A B (space) | B is anywhere inside A (any depth) |
| Child | A > B | B is a direct child of A only |
| Adjacent sibling | A + B | B immediately follows A (same parent) |
| General sibling | A ~ B | B follows A anywhere (same parent, any distance) |
/* ── Descendant — broad, targets all nested matches ── */
.card p { color: #666; } /* all p inside .card */
.nav a { color: #fff; } /* all links in nav */
/* ── Child — precise, avoids unintended nested matches ── */
.dropdown > li { border-bottom: 1px solid #eee; }
/* only first-level li items, not li inside nested ul */
/* ── Adjacent sibling — styling "the thing after" ── */
h2 + p { margin-top: 0; } /* first paragraph after a heading */
input + label { font-weight: bold; }
/* ── General sibling — all following matches ── */
.error ~ .field { border-color: red; } /* all .field after .error */
/* ── Chaining combinators ── */
.sidebar > ul > li > a { display: block; } /* precise drill-down */
.nav a is fine, but div div div a forces the engine to check three ancestor levels. In large DOMs, prefer adding a class or using a child combinator to limit traversal depth.
2 — Attribute Selectors
Attribute selectors match elements based on their HTML attributes and attribute values. They use square brackets: [attr]. They are especially powerful for targeting links, inputs, and custom data- attributes without adding extra classes.
| Selector | Matches when… | Example |
|---|---|---|
[attr] | Attribute exists (any value or empty) | [disabled] |
[attr="val"] | Exact match | [type="checkbox"] |
[attr^="val"] | Value starts with val | [href^="https"] |
[attr$="val"] | Value ends with val | [href$=".pdf"] |
[attr*="val"] | Value contains val anywhere | [class*="icon"] |
[attr~="val"] | Space-separated list contains word val | [class~="btn"] |
[attr|="val"] | Equals val or starts with val- (language codes) | [lang|="en"] |
[attr="val" i] | Case-insensitive match (add i flag) | [href$=".PDF" i] |
/* Automatically add an icon after external links */
a[href^="https"]::after {
content: " ↗";
font-size: 0.75em;
opacity: 0.7;
}
/* PDF link: different colour + icon */
a[href$=".pdf"],
a[href$=".PDF" i] { /* case-insensitive — matches .PDF too */
color: #e8a87d;
}
/* Style all disabled form controls */
input[disabled],
button[disabled],
select[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
/* data-* attributes as style hooks — no class needed */
[data-status="active"] { border-left: 3px solid #7ecfa0; }
[data-status="inactive"] { opacity: 0.5; }
/* Input types */
input[type="text"] { border-radius: 4px; }
input[type="submit"] { background: #4f8ef7; }
~= treats the attribute value as a space-separated word list — [class~="btn"] matches class="btn btn-primary" but NOT class="btn-primary". The asterisk *= does a substring match anywhere — [class*="btn"] matches both. For class attributes specifically, the ~= operator behaves exactly like checking for a class name.
3 — Structural Pseudo-classes
Structural pseudo-classes select elements based on their position in the document tree — their relationship to their siblings and parents — without needing any attribute or class.
Position pseudo-classes
| Pseudo-class | Selects |
|---|---|
:first-child | Element that is the first child of its parent |
:last-child | Element that is the last child of its parent |
:only-child | Element that is the only child of its parent |
:first-of-type | First element of its tag type among siblings |
:last-of-type | Last element of its tag type among siblings |
:only-of-type | Only element of its tag type among siblings |
:nth-child(n) | Element at position n (see An+B below) |
:nth-last-child(n) | Same but counting from the end |
:nth-of-type(n) | nth sibling of the same tag type |
:nth-last-of-type(n) | Same but counting from the end |
:empty | Element with no children (not even text nodes) |
li:first-child { font-weight: bold; } /* first list item */
li:last-child { border-bottom: none; } /* remove last separator */
/* Remove top margin from the very first element in an article */
.article > *:first-child { margin-top: 0; }
.article > *:last-child { margin-bottom: 0; }
/* Hide a badge when it has no content */
.badge:empty { display: none; }
/* Only-child: different treatment for solo items */
.card:only-child { max-width: 480px; margin: 0 auto; }
4 — The An+B Formula
:nth-child() and its relatives accept an expression in the form An+B where n is a counter that starts at 0 and increments by 1 forever. Plug in each value of n to find the matching positions.
A is the cycle size (step), B is the offset. The formula 3n+1 generates the sequence: 3(0)+1=1, 3(1)+1=4, 3(2)+1=7, 3(3)+1=10… — every third element starting from the first. Negative values of A or B clip the sequence: -n+3 generates 3, 2, 1 — then 0 and negative numbers are ignored, giving you elements 1, 2, 3 only (the first three).
li:nth-child(odd) /* 1, 3, 5, 7… — alias for 2n+1 */
li:nth-child(even) /* 2, 4, 6, 8… — alias for 2n */
li:nth-child(3) /* exactly the 3rd item */
li:nth-child(3n) /* 3, 6, 9, 12… — every 3rd */
li:nth-child(3n+1) /* 1, 4, 7, 10… — 1st of each 3 */
li:nth-child(-n+3) /* 1, 2, 3 — first three */
li:nth-child(n+4) /* 4, 5, 6, 7… — from 4th onward */
li:nth-last-child(1) /* the very last item */
li:nth-last-child(-n+2) /* last two items */
/* Combine to create a range: from 4th to 8th */
li:nth-child(n+4):nth-child(-n+8) { background: #1a2a4a; }
5 — :nth-child vs :nth-of-type
This is the most common source of confusion with structural selectors. The difference is subtle but important.
:nth-child(n)— counts all siblings regardless of tag, then checks if the element at position n also matches the rest of the selector.:nth-of-type(n)— counts only siblings with the same tag, then selects the nth one. The tag is part of what is counted.
<article>
<h2>Title</h2> <!-- child 1, h2 #1 -->
<p>First para.</p> <!-- child 2, p #1 -->
<p>Second para.</p> <!-- child 3, p #2 -->
<blockquote>…</blockquote> <!-- child 4 -->
<p>Third para.</p> <!-- child 5, p #3 -->
</article>
/* article p:nth-child(2)
→ finds child #2 of article, checks if it's a p
→ matches "First para." ✓ (it IS both 2nd child AND a p) */
/* article p:nth-child(3)
→ finds child #3, checks if it's a p
→ matches "Second para." ✓ */
/* article p:nth-child(4)
→ finds child #4, checks if it's a p
→ NO match — child #4 is a blockquote, not a p ✗ */
/* article p:nth-of-type(2)
→ among p siblings only (ignores h2, blockquote)
→ selects the 2nd p: "Second para." ✓ */
/* article p:nth-of-type(3)
→ selects the 3rd p: "Third para." ✓
→ works even though it's child #5, not #3 */
li, all div.card), both work identically. If siblings are mixed tags (like an article with h2, p, blockquote), use :nth-of-type to target a specific tag by its own count. Or better yet, add a class and use :nth-child with the class selector.p:nth-of-type(2) counts only p elements. .card:nth-of-type(2) does NOT count by class — it counts by tag, then filters by class. So if your cards are all div.card, it counts all div siblings, which is rarely what you want. For class-based patterns, stick with :nth-child.
6 — :not(), :is(), and :where()
:not() — the negation pseudo-class
:not(selector) selects elements that do not match the given selector. Modern browsers support a comma-separated list inside :not().
/* All links except those inside nav */
a:not(nav a) { text-decoration: underline; }
/* All list items except the last (no trailing border) */
li:not(:last-child) { border-bottom: 1px solid #eee; }
/* Inputs that are neither disabled nor read-only */
input:not([disabled]):not([readonly]) {
border-color: #4f8ef7;
}
/* Modern: comma list inside :not() */
:not(h1, h2, h3, h4) { font-size: 1rem; }
/* Cards except .featured */
.card:not(.featured) { opacity: 0.7; }
:is() — match-any grouping (with specificity)
:is() takes a selector list and matches any element that matches any selector in the list. It eliminates repetitive compound selectors and is forgiving — invalid selectors inside are silently ignored rather than breaking the whole rule.
/* Before :is() — four separate rules */
.card h1,
.card h2,
.card h3,
.card h4 { color: #fff; }
/* After :is() — one rule */
.card :is(h1, h2, h3, h4) { color: #fff; }
/* :is() on the ancestor side */
:is(article, section, aside) p {
line-height: 1.8;
}
/* equivalent to:
article p, section p, aside p { line-height: 1.8; } */
:is(h1, .title) has class-level specificity (0,1,0) because .title is the most specific argument inside.:where() — zero-specificity grouping
:where() is identical to :is() in what it matches, but it always contributes zero specificity. Use it for base/reset styles that you want to be easily overrideable.
/* :is() — uses highest specificity of its arguments */
:is(h1, h2, h3) { margin-top: 1.5rem; }
/* specificity: 0,0,1 (element-level — h1/h2/h3 are type selectors) */
:is(h1, .page-title) { font-size: 2rem; }
/* specificity: 0,1,0 (class-level — .page-title dominates) */
/* :where() — always zero specificity */
:where(h1, h2, h3, h4, h5, h6) { font-family: system-ui; }
/* specificity: 0,0,0 — any later rule beats this */
/* Great for resets and base styles */
:where(ul, ol) { list-style: none; padding: 0; }
/* user can easily override: nav ul { list-style: disc; } */
:has() pseudo-class is the "parent selector" that CSS lacked for decades — div:has(img) targets any div that contains an image. It is fully supported in modern browsers and is one of the most transformative selectors added to CSS in years. It is covered in CSS Advanced Chapter 4.
7 — Quick Reference
Combinators
| Combinator | Syntax | Matches |
|---|---|---|
| Descendant | A B | B anywhere inside A |
| Child | A > B | B that is a direct child of A |
| Adjacent sibling | A + B | B immediately after A, same parent |
| General sibling | A ~ B | All B siblings after A, same parent |
Attribute selectors
| Selector | Match type |
|---|---|
[attr] | Attribute exists |
[attr="val"] | Exact value |
[attr^="val"] | Starts with |
[attr$="val"] | Ends with |
[attr*="val"] | Contains substring |
[attr~="val"] | Contains word (space-separated) |
[attr="val" i] | Case-insensitive match |
An+B cheat sheet
| Expression | Meaning | Matches (of 12) |
|---|---|---|
odd / 2n+1 | Odd-numbered | 1,3,5,7,9,11 |
even / 2n | Even-numbered | 2,4,6,8,10,12 |
3n | Every 3rd | 3,6,9,12 |
4n+1 | 1st of each group of 4 | 1,5,9 |
-n+3 | First three | 1,2,3 |
n+4 | From 4th onwards | 4,5,6,…12 |
n+3 combined with -n+7 | Range: 3rd to 7th | 3,4,5,6,7 |
Functional pseudo-classes
| Pseudo-class | Specificity | Purpose |
|---|---|---|
:not(A) | Specificity of A | Exclude matches |
:is(A, B, C) | Highest in list | Match-any grouping, forgiving |
:where(A, B, C) | Always 0 | Match-any with no specificity weight |
✏️ Exercises
Solve each exercise using only CSS selectors — no added classes or HTML changes.
<ul class="menu"> containing several <li> elements separated by a bottom border. Write one selector to add the border to every item except the last, instead of adding a border to all and resetting the last. Then write a second rule that makes the very first item bold, and a third that styles every other item (starting from the 2nd) with a subtle background.:not(:last-child) for the border. :first-child for bold. :nth-child(even) for alternating rows..menu li:not(:last-child) {
border-bottom: 1px solid #2a2d3a;
}
.menu li:first-child {
font-weight: bold;
}
.menu li:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
↗) after every link that points to an external URL (starts with https), and a lock icon (🔒) after every link whose data-auth="required" attribute is set. Do not use JavaScript or add classes — use only attribute selectors and ::after.a[href^="https"]::after { content: " ↗"; } and a[data-auth="required"]::after.a[href^="https"]::after {
content: " ↗";
font-size: 0.75em;
opacity: 0.65;
vertical-align: super;
}
a[data-auth="required"]::after {
content: " 🔒";
font-size: 0.8em;
}
.card elements. Style the layout so: (a) every card gets a base border; (b) the first card spans 2 columns and gets a highlighted background; (c) every 3rd card gets an orange accent border; (d) the last two cards get reduced opacity. Use only structural pseudo-classes — no extra classes on the cards.:first-child for (b), :nth-child(3n) for (c), and :nth-last-child(-n+2) for (d)..card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* (a) base */
.card {
border: 1px solid #2a2d3a;
border-radius: 6px;
padding: 20px;
}
/* (b) first card spans 2 columns */
.card:first-child {
grid-column: span 2;
background: #1a2a4a;
border-color: #4f8ef7;
}
/* (c) every 3rd card */
.card:nth-child(3n) {
border-color: #e8a87d;
}
/* (d) last two cards */
.card:nth-last-child(-n+2) {
opacity: 0.5;
}
:is() to collapse the rules into fewer lines. Then write an alternative version using :where() and explain in a comment why you might prefer :where() for reset-style rules.:is(). The specificity difference is the key for the :where() explanation./* Original — repetitive */
.card h2, .card h3, .card h4 { color: #fff; }
.modal h2, .modal h3, .modal h4 { color: #fff; }
.sidebar h2, .sidebar h3, .sidebar h4 { color: #fff; }
:is(.card, .modal, .sidebar) :is(h2, h3, h4) {
color: #fff;
}
/* Specificity: 0,1,0 — class-level, because .card is the
most specific argument in the first :is() */
:where(.card, .modal, .sidebar) :where(h2, h3, h4) {
color: #fff;
}
/* Specificity: 0,0,0 — zero, so any later rule beats this.
Prefer :where() for base/reset styles in a design system
so component authors can override without specificity
battles. Use :is() when you want the specificity to count. */