Lesson 20
Selectors Deep-Dive - Pseudo-Classes, Structural & Form Selectors
Lesson 13 covered the functional pseudo-classes (:has(), :is(), :where(), :not()). This lesson covers everything else - the structural, form state, UI state, and link pseudo-classes you'll use daily. Think of this as the complete selector vocabulary.
Structural pseudo-classes - selecting by position
The :nth-child gotcha - it doesn't mean what you think
This is the single most common selector misunderstanding in CSS. Most people read p:nth-child(2) and think it means "the 2nd <p> inside its parent." It doesn't.
It actually means: "an element that is both a <p> AND the 2nd child of its parent." Two independent conditions, both must be true. The type selector does not change what gets counted - it only filters whether the match applies.
The mental model: Think of p:nth-child(2) as two separate filters applied simultaneously:
1. :nth-child(2) - "is this element at position 2 among ALL siblings?"
2. p - "is this element a <p>?"
Both must pass. The element type doesn't influence the counting - all siblings are counted regardless of type.
The gotcha in action - p:nth-child(2) highlights the FIRST paragraph (because it's the 2nd child overall)
Parent container with p:nth-child(2) applied:
Heading
First paragraph
Second paragraph
Child #1 = <h3> · Child #2 = first <p> · Child #3 = second <p>
p:nth-child(2) → "Is child #2 a <p>?" Yes! → matches the first paragraph.
p:nth-child(1) → "Is child #1 a <p>?" No, it's an <h3>. → matches nothing.
<!-- HTML -->
<div>
<h3>Heading</h3> <!-- child #1 -->
<p>First paragraph</p> <!-- child #2 -->
<p>Second paragraph</p> <!-- child #3 -->
</div>
/* What most people THINK this does: "select the 2nd <p>" */
/* What it ACTUALLY does: "select an element that is a <p> AND is the 2nd child" */
p:nth-child(2) { background: #d4f5e9; border-left: 3px solid #2a9d8f; }
/* This matches NOTHING — child #1 is an h3, not a p */
p:nth-child(1) { background: red; }
What you probably want: :nth-of-type
If your intent is "the 2nd <p> regardless of other siblings," use :nth-of-type. It counts only elements of the same type:
p:nth-of-type(2) selects the 2nd <p>, ignoring the <h3> entirely
Same HTML, using p:nth-of-type(2) instead:
Heading
First paragraph
Second paragraph
p:nth-of-type(2) → counts only <p> siblings: 1st p, 2nd p ✓. The <h3> is invisible to the count.
<!-- Same HTML -->
<div>
<h3>Heading</h3>
<p>First paragraph</p>
<p>Second paragraph</p> <!-- ← this one matches -->
</div>
/* Counts only <p> elements among siblings — ignores the <h3> */
p:nth-of-type(2) { background: #d4f5e9; border-left: 3px solid #2a9d8f; }
The naming is the enemy. :nth-child sounds like it should scope its counting to "children of this type" - but it doesn't. It counts ALL siblings. The type selector is just an additional filter applied after counting. The name lies to you.
Rule of thumb: If you want "the Nth <foo>", reach for :nth-of-type. Use :nth-child when you care about absolute position among all siblings (e.g., "every odd row in a list where all children are the same element type").
Basic structural selectors in action
:first-child (blue), :last-child (coral), :nth-child(3) (teal), odds have grey background
- Item 1 (:first-child → blue + bold)
- Item 2
- Item 3 (:nth-child(3) → teal)
- Item 4
- Item 5 (:last-child → coral + bold)
<!-- HTML — all children are <li>, so :nth-child works intuitively here -->
<ul class="list">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
</ul>
/* When all siblings are the same type, :nth-child is safe and intuitive */
li:first-child { font-weight: 600; color: blue; }
li:last-child { font-weight: 600; color: coral; }
li:nth-child(3) { color: teal; }
li:nth-child(odd) { background: #f8f8fa; }
li:nth-child(even) { background: white; }
:nth-child() - the formula
:nth-child() accepts a formula: An+B where A is the step and B is the offset:
:nth-child(odd) /* = :nth-child(2n+1) — 1st, 3rd, 5th... */
:nth-child(even) /* = :nth-child(2n) — 2nd, 4th, 6th... */
:nth-child(3) /* exactly the 3rd child */
:nth-child(3n) /* every 3rd: 3, 6, 9, 12... */
:nth-child(3n+1) /* every 3rd starting from 1st: 1, 4, 7, 10... */
:nth-child(n+4) /* everything from the 4th onward: 4, 5, 6, 7... */
:nth-child(-n+3) /* only the first 3: 1, 2, 3 */
:nth-last-child(2) /* 2nd from the end */
Reading the formula: An+B = "start at position B, then repeat every A." So 3n+2 = positions 2, 5, 8, 11... Think of A as the interval and B as the starting point.
:nth-child() with selector filtering (of S syntax)
A newer feature: :nth-child(An+B of S) counts only elements matching selector S:
/* Select every 2nd .highlighted item (ignoring non-highlighted ones) */
:nth-child(2n of .highlighted) { }
/* First visible item (counting only items without .hidden) */
:nth-child(1 of :not(.hidden)) { }
/* Every 3rd card in a grid, but only counting .card elements */
:nth-child(3n of .card) { }
Full structural selector reference
| Selector | Matches |
|---|---|
:first-child | First child of its parent |
:last-child | Last child of its parent |
:nth-child(An+B) | Child at position An+B |
:nth-last-child(An+B) | Same, counting from the end |
:only-child | Element is the only child of its parent |
:first-of-type | First element of its type among siblings |
:last-of-type | Last element of its type among siblings |
:nth-of-type(An+B) | nth element of its type |
:only-of-type | Only element of its type among siblings |
:empty | Element has no children (not even text) |
:nth-of-type limitation: :nth-of-type can only filter by element type (tag name). It can't filter by class or attribute. If you need "the 3rd .active item," you need the of S syntax covered above - :nth-child(3 of .active). That tells :nth-child to count only elements matching the selector .active, giving you class-based positional selection that :nth-of-type can't do.
Form state pseudo-classes
These select form elements based on their current state - incredibly useful for styling forms without JS:
Type in the fields - :valid/:invalid/:focus/:placeholder-shown all react live
<!-- HTML -->
<input type="email" placeholder="Email (try typing valid/invalid)">
<input type="text" required placeholder="Required field (type something)">
<input type="text" placeholder="I have dashed border while empty (placeholder-shown)">
<input type="text" value="I'm disabled" disabled>
/* CSS — form state pseudo-classes */
input:focus { border-color: blue; outline: 2px solid rgba(0,0,255,0.3); }
input:valid { border-color: green; }
input:invalid { border-color: red; }
input:placeholder-shown { border-style: dashed; } /* field is empty (placeholder visible) */
input:disabled { opacity: 0.5; background: #f0f0f0; }
Full form state reference
| Selector | Matches |
|---|---|
:focus | Element has keyboard/mouse focus |
:focus-visible | Focus, but only when the browser deems it should be visible (keyboard, not mouse click) |
:focus-within | Element or any descendant has focus (useful for styling a form group) |
:valid | Input passes validation constraints |
:invalid | Input fails validation |
:required | Input has the required attribute |
:optional | Input does not have required |
:checked | Checkbox/radio is checked |
:indeterminate | Checkbox is in indeterminate state (set via JS) |
:disabled | Element is disabled |
:enabled | Element is not disabled |
:placeholder-shown | Input is showing its placeholder (i.e. empty) |
:read-only | Input has readonly attribute |
:in-range | Number/range input within min/max |
:out-of-range | Number/range input outside min/max |
:focus vs :focus-visible: :focus fires on any focus (click or keyboard). :focus-visible only fires when the browser thinks a focus indicator should be shown (typically keyboard navigation). Use :focus-visible for outline styles so mouse users don't see outlines but keyboard users do.
User interaction pseudo-classes
:hover /* mouse is over it */
:active /* being clicked/pressed right now */
:visited /* link the user has visited before */
:link /* unvisited link */
:target /* element whose ID matches the URL fragment (#section) */
:fullscreen /* element is in fullscreen mode */
:target - styling based on URL hash
Click a link - the targeted section gets highlighted via :target
Jump to Section A | Jump to Section B
<!-- HTML -->
<a href="#section-a">Go to A</a>
<a href="#section-b">Go to B</a>
<div id="section-a">Section A content</div>
<div id="section-b">Section B content</div>
/* CSS — style the element whose ID matches the URL #hash */
:target {
background: #fff3e0;
border: 2px solid coral;
}
How :target works: It's driven by whatever is in the address bar right now - specifically the #fragment part. If the address bar shows https://example.com/page.html#sec-a, then :target matches the element with id="sec-a". It's literally location.hash driving a CSS selector in real time.
There's only ever one fragment in the URL at a time, so there's only ever zero or one :target element on the page. When you click <a href="#sec-b">, the browser updates the address bar → :target stops matching #sec-a and starts matching #sec-b. No fragment in the URL at all → nothing matches.
Attribute selectors - beyond classes and IDs
/* Has the attribute at all */
[data-tooltip] { cursor: help; }
/* Attribute equals exact value */
[type="email"] { }
[role="button"] { }
/* Attribute starts with */
[href^="https"] { } /* links starting with https */
[class^="icon-"] { } /* classes starting with icon- */
/* Attribute ends with */
[src$=".svg"] { } /* images ending in .svg */
[href$=".pdf"] { } /* links to PDFs */
/* Attribute contains */
[class*="card"] { } /* class contains "card" anywhere */
/* Attribute word list contains (space-separated) */
[class~="featured"] { } /* same as .featured */
/* Case-insensitive matching */
[type="email" i] { } /* matches "Email", "EMAIL", etc. */
When to use attribute selectors over classes: When styling based on HTML semantics ([type="email"], [aria-expanded="true"], [data-state="open"]) rather than presentational classes. Also useful for external content you can't add classes to (e.g. CMS-generated HTML, third-party widgets).
Combining selectors - practical recipes
/* Style external links differently (href starts with http, not your domain) */
a[href^="http"]:not([href*="yourdomain.com"]) {
&::after { content: " ↗"; }
}
/* Required fields that are empty (show a subtle indicator) */
input:required:placeholder-shown {
border-left: 3px solid coral;
}
/* First item that ISN'T hidden */
li:not(.hidden):first-child { }
/* Or with the newer "of" syntax: */
li:nth-child(1 of :not(.hidden)) { }
/* Table rows on hover, but not the header */
tr:not(:first-child):hover {
background: #f0f7ff;
}
/* Style a form group when any field inside has focus */
.form-group:focus-within {
border-color: blue;
box-shadow: 0 0 0 3px rgba(0,0,255,0.1);
}
/* Cards that contain images AND are not the first card */
.card:has(img):not(:first-child) {
margin-top: 2rem;
}
Retrieval check
Question 1
Given <div> containing: <h2>, <p>, <p> - what does p:nth-child(1) select?
Question 2
You want "the 2nd <p> among siblings of mixed types." Which is the classic, widely-supported selector for this?
Question 3
What's the difference between :focus and :focus-visible?
Question 4
What does :placeholder-shown match?
Question 5
What does [href$=".pdf"] select?