← Back to Index

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) { }
Ad

Full structural selector reference

Selector Matches
:first-childFirst child of its parent
:last-childLast child of its parent
:nth-child(An+B)Child at position An+B
:nth-last-child(An+B)Same, counting from the end
:only-childElement is the only child of its parent
:first-of-typeFirst element of its type among siblings
:last-of-typeLast element of its type among siblings
:nth-of-type(An+B)nth element of its type
:only-of-typeOnly element of its type among siblings
:emptyElement 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
:focusElement has keyboard/mouse focus
:focus-visibleFocus, but only when the browser deems it should be visible (keyboard, not mouse click)
:focus-withinElement or any descendant has focus (useful for styling a form group)
:validInput passes validation constraints
:invalidInput fails validation
:requiredInput has the required attribute
:optionalInput does not have required
:checkedCheckbox/radio is checked
:indeterminateCheckbox is in indeterminate state (set via JS)
:disabledElement is disabled
:enabledElement is not disabled
:placeholder-shownInput is showing its placeholder (i.e. empty)
:read-onlyInput has readonly attribute
:in-rangeNumber/range input within min/max
:out-of-rangeNumber/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

Section A - I highlight when the URL hash is #sec-a
Section B - I highlight when the URL hash is #sec-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?

The first paragraph
Nothing - child #1 is an h2, not a p
The h2 element
Both paragraphs

Question 2

You want "the 2nd <p> among siblings of mixed types." Which is the classic, widely-supported selector for this?

p:nth-child(2) - counts only p elements
p:nth-of-type(2) - counts only p elements
div > p:nth-child(2) - scopes to div
p:nth-child(2n) - selects even p elements

Question 3

What's the difference between :focus and :focus-visible?

:focus-visible is for visible elements, :focus is for hidden ones
:focus fires on any focus; :focus-visible only when browser thinks indicator should show (keyboard nav)
:focus-visible has higher specificity
They're identical - :focus-visible is just newer syntax

Question 4

What does :placeholder-shown match?

An input that has a placeholder attribute defined
An input currently showing its placeholder (i.e. the field is empty)
The placeholder text itself
Any input that has ever shown a placeholder

Question 5

What does [href$=".pdf"] select?

Links that contain ".pdf" anywhere
Links that start with ".pdf"
Links whose href ends with ".pdf"
Only the first PDF link on the page