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.

CombinatorSymbolMeaning
DescendantA B (space)B is anywhere inside A (any depth)
ChildA > BB is a direct child of A only
Adjacent siblingA + BB immediately follows A (same parent)
General siblingA ~ BB follows A anywhere (same parent, any distance)
Live demo — matched elements highlighted in blue
.scope
<h3>Heading
<p>Paragraph 1
<p>Paragraph A (nested)
<p>Paragraph B (nested)
<p>Paragraph 2
<p>Paragraph 3
/* click a button to see which elements match */
🎨 CSS — practical combinator uses
/* ── 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 */
⚠️ The descendant combinator is the slowest to evaluate. The browser reads selectors right-to-left — it first finds all elements matching the right side, then checks ancestors. A selector like .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.

Live demo — matched items highlighted
External linkhref="https://example.com"
Internal: /abouthref="/about"
Internal: /contacthref="/contact"
Email linkhref="mailto:hi@example.com"
PDF downloadhref="report.pdf"
Image linkhref="photo.jpg"
Premium articledata-type="premium"
Free articledata-type="free"
/* click a button to see which items match */
SelectorMatches 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]
🎨 CSS — practical attribute selector patterns
/* 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; }
💡 [attr~="val"] vs [attr*="val"]. The tilde ~= 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-classSelects
:first-childElement that is the first child of its parent
:last-childElement that is the last child of its parent
:only-childElement that is the only child of its parent
:first-of-typeFirst element of its tag type among siblings
:last-of-typeLast element of its tag type among siblings
:only-of-typeOnly 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
:emptyElement with no children (not even text nodes)
🎨 CSS — structural pseudo-class patterns
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.

Reading An+B: 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).
Live demo — 12 items, purple = matched (child elements 1–12)
1
2
3
4
5
6
7
8
9
10
11
12
/* click a button to highlight matching positions */
🎨 CSS — An+B patterns decoded
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.
📄 HTML — mixed siblings
<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>
🎨 CSS — how the two selectors differ on the HTML above
/* 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 */
Rule of thumb: If your list/grid is a sequence of the same tag (all 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.
⚠️ :nth-of-type is tag-specific. 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().

🎨 CSS — :not() patterns
/* 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.

🎨 CSS — :is() cleans up repetition
/* 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; } */
Specificity of :is(): takes the specificity of its most specific argument. :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.

🎨 CSS — :is() vs :where() specificity
/* :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; } */
Coming in the Advanced course: :has(). The :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

CombinatorSyntaxMatches
DescendantA BB anywhere inside A
ChildA > BB that is a direct child of A
Adjacent siblingA + BB immediately after A, same parent
General siblingA ~ BAll B siblings after A, same parent

Attribute selectors

SelectorMatch 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

ExpressionMeaningMatches (of 12)
odd / 2n+1Odd-numbered1,3,5,7,9,11
even / 2nEven-numbered2,4,6,8,10,12
3nEvery 3rd3,6,9,12
4n+11st of each group of 41,5,9
-n+3First three1,2,3
n+4From 4th onwards4,5,6,…12
n+3 combined with -n+7Range: 3rd to 7th3,4,5,6,7

Functional pseudo-classes

Pseudo-classSpecificityPurpose
:not(A)Specificity of AExclude matches
:is(A, B, C)Highest in listMatch-any grouping, forgiving
:where(A, B, C)Always 0Match-any with no specificity weight

✏️ Exercises

Solve each exercise using only CSS selectors — no added classes or HTML changes.

Exercise 1
You have a <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.
Hint: :not(:last-child) for the border. :first-child for bold. :nth-child(even) for alternating rows.
CSS
.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); }
Exercise 2
Write CSS that automatically adds a small arrow icon () 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.
Hint: a[href^="https"]::after { content: " ↗"; } and a[data-auth="required"]::after.
CSS
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; }
Exercise 3
You have a grid of 9 .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.
Hint: Use :first-child for (b), :nth-child(3n) for (c), and :nth-last-child(-n+2) for (d).
CSS
.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; }
Exercise 4
Rewrite this repetitive CSS using :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.
Hint: Both sides of the combinator can use :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; }
CSS — using :is()
: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() */
CSS — using :where() for a reset
: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. */