Chapter 4 — Deep Dive: Selectors
CSS has far more selectors than most developers use day to day. Knowing the full
set means writing less JavaScript for DOM queries, producing more resilient stylesheets,
and understanding exactly what specificity each rule carries. This chapter covers every
selector type — simple, combinators, attribute, structural pseudo-classes, state
pseudo-classes, functional pseudo-classes, pseudo-elements, and the modern relational
selectors — with practical examples and specificity scores throughout.
1. Simple Selectors
| Selector | Specificity | What it matches |
| Simple |
| * |
0-0-0 |
Universal — every element. Zero specificity. Use for resets and box-sizing only. |
| div |
0-0-1 |
Type selector — matches any element of that tag name. Prefer classes over element selectors for styling. |
| .class |
0-1-0 |
Class selector — the workhorse. Can be chained: .card.featured matches elements with both classes. |
| #id |
1-0-0 |
ID selector. High specificity — avoid in CSS. IDs should drive JavaScript and URL fragments, not styles. Each ID should appear once per page. |
2. Combinators
Combinators express relationships between selectors. They don't add their own
specificity — only the selectors on either side contribute.
(space)
Descendant
nav a
Matches a anywhere inside nav — any depth. The most common combinator. Greedy: hits deeply nested elements too.
>
Child
ul > li
Matches li that is a direct child of ul only. Does not match nested lists' items. More precise than descendant.
+
Adjacent sibling
h2 + p
Matches the immediately following sibling. The p must come right after h2 with no elements in between.
~
General sibling
h2 ~ p
Matches all subsequent siblings. The p elements can be anywhere after h2 within the same parent.
||
Column
col.selected || td
Targets cells belonging to a column. Experimental — limited browser support. For table column styling.
/* Combinator patterns in practice */
/* Lobotomised owl — add space above every element that follows another */
.stack > * + * {
margin-top: 1rem;
}
/* Adjacent sibling to style the paragraph after a heading differently */
h2 + p {
font-size: 1.1em;
color: #79c0ff; /* lead paragraph style */
}
/* General sibling — all checked radio's subsequent labels */
input[type="radio"]:checked ~ label {
font-weight: bold;
}
3. Attribute Selectors
Attribute selectors match elements based on the presence or value of HTML attributes.
All carry class-level specificity (0-1-0).
| Selector | Matches when… |
| [attr] |
Attribute exists (any value, even empty) |
| [attr="val"] |
Attribute equals exactly "val" |
| [attr~="val"] |
Attribute is a space-separated list containing "val" (class-like matching) |
| [attr|="val"] |
Attribute equals "val" or starts with "val-" (language code matching: [lang|="en"] matches en, en-US, en-GB) |
| [attr^="val"] |
Attribute starts with "val" |
| [attr$="val"] |
Attribute ends with "val" — great for file extension targeting |
| [attr*="val"] |
Attribute contains "val" anywhere |
| [attr="val" i] |
Case-insensitive match (append i before closing bracket) |
| [attr="val" s] |
Case-sensitive match (append s — useful in SVG/XML contexts) |
/* Attribute selector real-world patterns */
/* Icon for external links */
a[href^="http"]::after {
content: ' \2197'; /* ↗ arrow */
}
/* Style PDF links differently */
a[href$=".pdf"] {
color: #ff7b72;
}
/* Style disabled form elements */
input[disabled],
button[disabled] {
opacity: 0.4;
cursor: not-allowed;
}
/* Target ARIA roles for styling — ties CSS to semantics */
[role="alert"] {
border: 2px solid #ff7b72;
padding: 1rem;
}
/* Language-aware typography */
:lang(ja) {
font-family: 'Noto Sans JP', sans-serif;
line-height: 1.9;
}
4. Pseudo-Classes
Pseudo-classes select elements based on state, position, or relationship — things
that can't be expressed with static HTML attributes. All carry class-level specificity
(0-1-0) unless they contain a selector argument.
User interaction state
:hover
Mouse pointer is over the element. Does not fire on touch devices unless tapped.
:focus
Element has keyboard or programmatic focus. Never remove the outline without providing an alternative.
:focus-visible
Focus ring only shows for keyboard users — not after mouse clicks. The correct approach to custom focus styles.
:focus-within
Element or any descendant has focus. Useful for styling a form container when any field inside it is active.
:active
Element is being activated (mouse button down). Use for press animations — scale down on :active for tactile feel.
:visited
Link has been visited. Privacy-restricted — only color, background-color, border-color, outline-color can be changed.
Form / input state
:checked
Checkbox or radio is checked. Combine with ~ to style adjacent labels — CSS-only toggle patterns.
:indeterminate
Checkbox is in the indeterminate state (neither checked nor unchecked — set via JS).
:disabled
Form element has the disabled attribute. Prefer this over [disabled] — it's more specific to form elements.
:enabled
Form element is not disabled. Rarely needed — useful when you need to explicitly re-style enabled elements in a context where many are disabled.
:placeholder-shown
Input has a visible placeholder (i.e. it's empty). Use to detect whether a field is empty — CSS-only float label pattern.
:required / :optional
Input has or lacks the required attribute. Style required fields to communicate expectation before submission.
:valid / :invalid
Input passes or fails HTML constraint validation. Fires immediately on page load — combine with :user-invalid to delay.
:user-valid / :user-invalid
Like :valid/:invalid but only fires after the user has interacted with the field. The correct approach for showing validation errors.
:in-range / :out-of-range
Number/date input value is inside or outside the min/max range.
:read-only / :read-write
Element has or lacks the readonly attribute. :read-write also matches contenteditable elements.
Structural — tree position
:first-child
Element is the first child of its parent (regardless of type).
:last-child
Element is the last child of its parent.
:only-child
Element is the only child of its parent. Useful for hiding UI that only makes sense with siblings (prev/next buttons).
:nth-child(n)
Matches by position. Accepts a number, keyword (odd/even), or formula (An+B). Counts all siblings, then filters by type.
:nth-last-child(n)
Same as nth-child but counted from the end. :nth-last-child(1) = :last-child.
:first-of-type
First sibling of that element type — ignores siblings of other types. Different from :first-child.
:last-of-type
Last sibling of that element type.
:only-of-type
Only sibling of that element type.
:nth-of-type(n)
nth sibling of that element type. Counts only siblings matching the element type.
:empty
Element has no children and no text (whitespace counts as content in some browsers — use carefully). Good for hiding empty containers.
:root
The root element (html in HTML documents). Higher specificity than the html type selector. Where CSS custom properties are declared.
:target
Element whose ID matches the URL fragment (#section). Used for CSS-only tab and accordion patterns, and scroll-linked highlighting.
Document / page
:any-link
Matches any anchor with an href — combines :link and :visited without writing both.
:link
An anchor with href that has not been visited.
:local-link
Link pointing to the current page. Limited support — use with care.
:scope
In stylesheets, same as :root. In JS querySelector(), represents the element the query is called on. Useful with @scope.
:fullscreen
Element is being displayed in fullscreen mode. Style fullscreen video players or presentations differently.
:picture-in-picture
Video element is currently in picture-in-picture mode.
:nth-child() — the An+B formula
/* An+B formula — A = step, B = offset, n starts at 0 */
li:nth-child(odd) { /* 1, 3, 5, 7 … = 2n+1 */ }
li:nth-child(even) { /* 2, 4, 6, 8 … = 2n+2 or 2n */ }
li:nth-child(3) { /* exactly the 3rd item */ }
li:nth-child(3n) { /* every 3rd: 3, 6, 9, 12 … */ }
li:nth-child(3n+1) { /* 1, 4, 7, 10 … (first item in each group of 3) */ }
li:nth-child(-n+3) { /* first 3 items only: 3, 2, 1 */ }
li:nth-child(n+4) { /* all items FROM the 4th onward */ }
/* Modern: :nth-child() now accepts a selector argument */
li:nth-child(2 of .featured) {
/* The 2nd li that has class .featured */
/* Counts only .featured items, skips the rest */
}
:nth-child vs :nth-of-type — the key difference.
:nth-child(2) selects an element if it is the 2nd child of its parent,
regardless of type. :nth-of-type(2) selects the 2nd sibling of the
same element type. If a <div> is the only div but the 4th child,
div:nth-child(2) won't match it, but div:nth-of-type(1) will.
5. Functional Pseudo-Classes
:is() — forgiving selector list
/* Without :is() — verbose */
header h1, header h2, header h3,
main h1, main h2, main h3,
footer h1, footer h2, footer h3 {
line-height: 1.2;
}
/* With :is() — compact */
:is(header, main, footer) :is(h1, h2, h3) {
line-height: 1.2;
}
/* :is() is "forgiving" — an invalid selector in the list is ignored */
:is(h1, h2, :unsupported-selector) {
/* the :unsupported-selector is skipped, h1 and h2 still match */
/* Traditional comma lists would discard the WHOLE rule if any selector is invalid */
}
/* Specificity: takes the highest specificity of any argument */
:is(#id, .class) p { /* specificity: (1-0-1) — #id raises the whole :is() */ }
:is(.a, .b, .c) p { /* specificity: (0-1-1) — highest in list is a class */ }
:where() — zero specificity
/* :where() is identical to :is() but ALWAYS contributes zero specificity */
:where(h1, h2, h3) {
margin-top: 0;
/* Specificity: (0-0-0) — any single class will override this */
}
/* Perfect for base/reset styles that should be easy to override */
:where(ul, ol) {
padding-left: 0;
list-style: none;
}
/* User code can override with a single class — no specificity battle */
.nav-list {
padding-left: 1rem; /* (0-1-0) — wins over :where()'s (0-0-0) */
}
:not() — exclusion
/* :not() excludes matching elements */
p:not(.intro) {
color: #8b949e; /* all p except .intro */
}
/* Modern :not() accepts a full selector list */
a:not(:hover, :focus, .active) {
text-decoration: none;
}
/* Specificity: takes the highest of its argument */
p:not(#special) { /* (1-0-1) — #special raises it to ID level */ }
p:not(.active) { /* (0-1-1) — class level + element */ }
p:not(span) { /* (0-0-2) — element level + element */ }
/* Practical: style all buttons except disabled ones */
button:not(:disabled):hover {
transform: translateY(-1px);
}
:has() — the parent selector (and more)
:has() is the most powerful selector added to CSS in years. It
selects an element based on what it contains — effectively a parent selector,
but more accurately a "relational" selector. Supported in all modern browsers since 2023.
/* Style a card differently when it contains an image */
.card:has(img) {
padding: 0;
}
/* Style a form when it contains an invalid field */
form:has(:user-invalid) {
border-color: #ff7b72;
}
/* Style a list item when its checkbox is checked */
li:has(input:checked) {
text-decoration: line-through;
opacity: 0.5;
}
/* Style the body when a modal is open */
body:has(dialog[open]) {
overflow: hidden; /* prevent scroll while modal is open */
}
/* :has() as previous sibling selector — targets element BEFORE the match */
h2:has(+ p.warning) {
color: #d29922; /* style h2 when immediately followed by a warning paragraph */
}
/* Quantity queries — style items when there are exactly 3 */
li:has(~ li:nth-child(3)):nth-child(1) {
/* complex but possible without JS */
}
/* :has() specificity takes the highest in its argument */
.card:has(img) { /* (0-1-1) — .card (class) + img (element) */ }
.card:has(.hero) { /* (0-2-0) — .card (class) + .hero (class) */ }
:has() does not accept pseudo-elements as its argument. You can't
write :has(::before). It also cannot be nested — :has(:has(...))
is invalid. But you can combine it with other pseudo-classes:
:has(:not(.excluded)) and :has(:is(img, video)) both work.
6. Pseudo-Elements
Pseudo-elements create virtual elements in the DOM. They use double-colon syntax
(::) to distinguish from pseudo-classes, and carry element-level
specificity (0-0-1).
| Pseudo-element | What it creates / selects |
| ::before |
Inserts a generated element as the first child. Requires content property (can be empty string). Cannot be added to void elements (img, input, br). |
| ::after |
Inserts a generated element as the last child. Same rules as ::before. Together they give every non-void element two free extra elements for decoration. |
| ::first-line |
The first rendered line of a block element. Only a subset of properties apply (color, font, text-decoration, background, word-spacing, letter-spacing). |
| ::first-letter |
The first character of a block element. Used for drop caps. Supports more properties than ::first-line including float, width, height. |
| ::selection |
Text selected by the user. Only color, background-color, and text-shadow can be changed. Used for brand-coloured text selection. |
| ::placeholder |
The placeholder text in an input. Style sparingly — placeholder text should have lower contrast than real input, not styled to look like a label. |
| ::marker |
The bullet or number of a list item. Supports color, font, content. Replace list-style-type with content in ::marker for full Unicode/emoji bullets. |
| ::backdrop |
The full-viewport overlay behind a <dialog> or fullscreen element. Style it to darken the background: background: rgb(0 0 0 / 0.5). |
| ::cue |
WebVTT cue boxes (subtitles/captions) on video elements. Style subtitle appearance without JavaScript. |
| ::file-selector-button |
The button part of <input type="file">. Finally lets you style file inputs without hiding them. |
/* ::before / ::after practical patterns */
/* Decorative quote mark */
blockquote::before {
content: '\201C'; /* " opening double quote */
font-size: 4rem;
line-height: 0;
color: #58a6ff;
}
/* Counter via ::before */
ol { counter-reset: step; list-style: none; }
li { counter-increment: step; }
li::before {
content: counter(step, decimal-leading-zero);
font-variant-numeric: tabular-nums;
color: #58a6ff;
margin-right: 0.75rem;
}
/* ::marker — custom bullet */
li::marker {
content: '→ ';
color: #3fb950;
}
/* ::selection */
::selection {
background-color: #1a3a5a;
color: #ffffff;
}
/* ::backdrop — style dialog overlay */
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
backdrop-filter: blur(4px);
}
Chapter Summary
| Concept | Key point |
| Combinators | Space = any descendant. > = direct child only. + = immediately following sibling. ~ = all following siblings. None add their own specificity. |
| Attribute selectors | ^= starts with, $= ends with, *= contains, ~= word in list, |= equals or starts with hyphen. All (0-1-0) specificity. Append i for case-insensitive. |
| :nth-child An+B | n starts at 0. -n+3 = first 3. n+4 = from 4th onward. odd = 2n+1, even = 2n. Modern browsers support "of .class" argument. |
| :nth-child vs :nth-of-type | nth-child counts all siblings then checks type. nth-of-type counts only siblings of the same type. A common source of confusion. |
| :is() | Forgiving selector list — invalid selectors are skipped. Takes specificity of most specific argument. Reduces repetition dramatically. |
| :where() | Identical to :is() but always (0-0-0) specificity. Use for base/reset styles that should be trivially overridable. |
| :has() | Selects an element based on its contents or following siblings. Parent selector, quantity queries, conditional styling without JavaScript. Supported in all modern browsers. |
| ::before / ::after | Require content property. Cannot be used on void elements. Two free generated elements per non-void element for decoration. |
| ::marker | Style list bullets with color and content — use content: to replace the bullet entirely with any character or emoji. |
| :focus-visible | Shows focus ring for keyboard navigation only — not after mouse clicks. The correct way to style focus without harming accessibility. |
| :user-valid / :user-invalid | Only fires after the user has interacted with an input. Prevents showing error states immediately on page load. |
Exercises
- Attribute selectors: Style all external links (href starting with http) with a small ↗ icon appended via
::after. Then style all PDF links (href ending in .pdf) in red. Use only attribute selectors — no classes.
- :nth-child patterns: Create a 6-item grid. Using only
:nth-child(), make item 1 span 2 columns, items 2–3 normal, item 4 have a different background, and items 5–6 appear at 50% opacity. No classes allowed.
- :has() todo list: Build an unordered list of tasks each containing a checkbox. When a checkbox is checked, style the parent
<li> with a strikethrough and reduced opacity using only :has(input:checked) — no JavaScript.
- :has() modal lock: Add a
<dialog> element to a page with an open/close button. Using body:has(dialog[open]), prevent body scrolling and dim the background — no JavaScript for the CSS part.
- ::marker customisation: Create an ordered list where each marker shows "Step N →" using CSS counters and
::marker { content: ... }. The counter should be styled in blue and larger than the list text.
Next: Chapter 5 — Deep Dive: Flexbox.
The complete flexbox reference — every container and item property, alignment axes,
flex-grow / flex-shrink / flex-basis demystified, wrapping, ordering, and
real-world layout patterns including navigation bars, card grids, and centring.