019: Selectors
Learning outcomes
By the end, you can choose and explain type, class, ID, attribute, descendant, child, grouping, and pseudo-class selectors; use classes as reusable styling hooks; and test selector matching in DevTools.
Prerequisites and retrieval
Use yesterday's portfolio and external stylesheet. Retrieve: identify the selector, property, and value in .project { color: navy; }. Predict whether adding another class="project" makes a new rule necessary. It does not: selectors describe a set of matching elements.
Terminology
- Subject: The rightmost compound selector in a complex selector — the element ultimately matched. — Source: Selectors 4: Complex selectors
- Type selector: Matches elements by node name, e.g., article. — Source: Selectors 4: Type selector
- Class selector: Matches elements whose class attribute contains the given identifier (.card). — Source: Selectors 4: Class selector
- ID selector: Matches the single element whose id equals the value (#contact). — Source: Selectors 4: ID selector
- Attribute selector: Matches elements by attribute presence or value patterns ([href^="https"]). — Source: Selectors 4: Attribute selectors
- Combinator: Expresses the relationship between two compound selectors: descendant space, > child, + next sibling, ~ subsequent siblings. — Source: Selectors 4: Combinators
- Selector list: Comma-separated selectors; each is matched independently against the same declarations. — Source: Selectors 4: Selector list
- Pseudo-class: A colon-prefixed condition describing state or structure (:hover, :first-child). — Source: Selectors 4: Pseudo-classes
- Compound selector: A chain of simple selectors on one element with no combinators (.project.featured). — Source: Selectors 4: Compound selector
- Universal selector (
*): "The universal selector matches any element." — Source: Selectors Level 4: Universal selector - Pseudo-element (
::): "A pseudo-element creates an abstraction about the document tree (e.g., ::before)." — Source: MDN: Pseudo-elements - Complex selector: "A chain of one or more compound selectors separated by combinators." — Source: Selectors Level 4: Complex selectors
Mental model: search patterns over a tree
Imagine the DOM as a family tree. A selector is a query, not a name for a declaration block. Read complex selectors from right to left: .project > h3 asks for each h3 whose parent is .project; .project a asks for each a anywhere inside .project.
Classes are the normal reusable styling hook. They can occur many times and an element can have several classes. IDs are valuable as fragment destinations, labels, or unique programmatic identifiers, but their high CSS specificity makes overrides harder. Prefer .contact over #contact when either could style the component.
Selector forms:
article {} /* type */
.project {} /* class */
#contact {} /* ID */
[aria-current="page"] {} /* attribute */
.project a {} /* descendant */
.project > h3 {} /* direct child */
h1, h2, h3 {} /* selector list */
a:hover {} /* pseudo-class */
.project.featured {} /* same element has both classes */
Whitespace changes meaning: .project.featured is one element with two classes; .project .featured is a featured descendant inside a project.
Beginner example: selector laboratory
Add this to the portfolio:
<nav aria-label="Primary">
<a href="#about" aria-current="page">About</a>
<a href="#projects">Projects</a>
<a href="#contact">Contact</a>
</nav>
<main>
<section id="projects">
<h2>Projects</h2>
<article class="project featured">
<h3>Library finder</h3>
<p>Find nearby public libraries.</p>
<a href="https://example.com/library">Live demo</a>
</article>
<article class="project">
<h3>Recipe notes</h3>
<p>Keep accessible cooking notes.</p>
<a href="project.html">Case study</a>
</article>
</section>
</main>
Apply selectors one by one:
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
}
h1,
h2,
h3 {
color: rgb(30 58 138);
}
.project {
margin-block: 1rem;
padding: 1rem;
border: 1px solid rgb(203 213 225);
}
.project.featured {
border-width: 3px;
}
.project > h3 {
margin-block-start: 0;
}
.project a {
font-weight: 700;
}
[href^="https"] {
text-decoration-style: double;
}
[aria-current="page"] {
font-weight: 700;
}
a:hover {
text-decoration-thickness: 0.2em;
}
a:focus-visible {
outline: 3px solid rgb(234 88 12);
outline-offset: 3px;
}
Predict first, then reload. The first three headings group together. Both articles match .project; only one matches .project.featured. The child selector affects direct h3 children, while the descendant selector catches nested links at any depth. [href^="https"] means “href begins with https.” State pseudo-classes respond without adding classes to HTML.
Use Tab to focus links, not only the mouse. :hover and :focus-visible answer different input states and should not be treated as interchangeable.
Intermediate example: reusable component variants
Build notification components:
<aside class="notice notice--info" aria-labelledby="info-title">
<h2 id="info-title">Portfolio review</h2>
<p>Add outcomes to each case study.</p>
<a href="#projects">Review projects</a>
</aside>
<aside class="notice notice--success">
<h2>Published</h2>
<p>Your latest case study is live.</p>
</aside>
.notice {
margin-block: 1rem;
padding: 1rem;
border-inline-start: 0.35rem solid rgb(71 85 105);
background: rgb(248 250 252);
}
.notice--info {
border-color: rgb(37 99 235);
background: rgb(239 246 255);
}
.notice--success {
border-color: rgb(21 128 61);
background: rgb(240 253 244);
}
.notice > :first-child {
margin-block-start: 0;
}
.notice > :last-child {
margin-block-end: 0;
}
The base class owns common design; variant classes change only what differs. :first-child and :last-child are structural pseudo-classes. The selector's subject is the child, not .notice. This avoids selectors tied to a fragile number of wrappers.
Optional advanced example: attribute semantics
Form controls already carry useful attributes:
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<button type="submit" disabled>Send</button>
input[required] { border-inline-start: 0.25rem solid rgb(180 83 9); }
input:focus-visible { outline: 3px solid rgb(37 99 235); outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: 0.65; }
Use selectors to reflect real state, but do not rely on CSS alone to communicate it. The required and disabled HTML semantics remain available to browsers and assistive technology.
Mistakes, debugging, and DevTools
- Forgetting
.or#:projecttargets a nonexistent element;.projecttargets a class. - Adding a space accidentally:
.notice --infois unrelated to.notice--info. - Confusing child and descendant:
>permits exactly one parent step; a space permits any depth. - Reusing IDs: each
idmust be unique in the document. - Invalid selector lists: in a normal comma-separated list, one invalid selector can invalidate the entire rule.
- Styling every link by partial URL without checking exceptions: inspect which elements actually match.
- Designing only
:hover: keyboard and touch users may never hover.
Inspect an element and read “Matched CSS Rules.” Browser DevTools often show selector specificity and highlight the matched part. In the Console, document.querySelectorAll('.project > h3') can confirm the matching set, although JavaScript knowledge is not required. Temporarily add outline: 3px solid magenta; to visualize targets.
Accessibility and performance
Selectors do not change semantics. A styled <div> does not become a button; use semantic HTML first. Preserve visible focus. Do not use display: none merely to make important text visually subtle because it removes content from rendering and the accessibility tree. Do not encode meaning only through .success green and .error red; include words, icons with text alternatives where needed, or other cues.
Modern browsers match ordinary selectors efficiently. Readability and maintainability matter more than selector micro-optimization. Avoid extremely long chains tied to every wrapper; a component class survives markup changes and reduces specificity.
Deep dive: selector families and combinators
Selectors are easiest to learn as families rather than isolated punctuation.
Simple selectors
* {} /* universal */
p {} /* type */
.card {} /* class */
#checkout {} /* ID */
[disabled] {} /* attribute presence */
[type="email"] {} /* exact attribute value */
Prefer stable classes for component styling. IDs are valid selectors but are usually unnecessarily specific for reusable visual rules.
Combinators describe relationships
Given:
<article class="card">
<h2>Course</h2>
<div class="meta">
<span class="badge">New</span>
</div>
<p>Learn modern CSS.</p>
</article>
<p class="note">Limited seats.</p>
Compare:
.card p {} /* any descendant p */
.card > p {} /* direct-child p only */
h2 + .meta {} /* immediately following sibling */
h2 ~ p {} /* later sibling p elements */
The spaces and symbols are part of the selector meaning. .card > p is not “more specific” because it uses >; it simply matches a different relationship.
Deep dive: pseudo-classes as state and structure queries
A pseudo-class matches an element based on state, position, or a relationship that is not expressed by a class name.
a:hover {}
button:focus-visible {}
input:disabled {}
input:checked {}
li:first-child {}
li:nth-child(2n) {}
article:not(.featured) {}
Use :focus-visible for a strong keyboard focus treatment without assuming that every pointer click needs the same ring:
:where(a, button, input, select, textarea):focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
}
Do not remove focus outlines unless you provide an equally visible replacement.
Functional selectors: :is(), :where(), :not(), and :has()
Without grouping:
.card h2,
.card h3,
.card h4 {
line-height: 1.2;
}
With :is():
.card :is(h2, h3, h4) {
line-height: 1.2;
}
:where() matches similarly but contributes zero specificity:
:where(.prose h2, .prose h3, .prose h4) {
margin-block-start: 1.5em;
}
:not() excludes matches:
.button:not(.button--primary) {
background: transparent;
}
:has() allows a subject to react to matching descendants or relatives:
.form-field:has(input:invalid) {
border-color: #b91c1c;
}
Use :has() to express a real structural relationship, not to replace clear component classes everywhere.
Deep dive: pseudo-elements style generated or partial boxes
Pseudo-elements target a part of an element or generate a presentation-only box:
.quote::before {
content: "“";
}
li::marker {
color: #2563eb;
}
::selection {
background: #fde68a;
color: #111827;
}
Generated ::before and ::after content should not carry essential meaning because it may not be exposed consistently to assistive technologies and is absent from the HTML source.
Worked example: accessible navigation states
<nav aria-label="Primary">
<a class="nav-link" href="/" aria-current="page">Home</a>
<a class="nav-link" href="/work">Work</a>
<a class="nav-link" href="/contact">Contact</a>
</nav>
.nav-link {
color: #334155;
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.nav-link:hover {
color: #0f172a;
}
.nav-link[aria-current="page"] {
color: #1d4ed8;
font-weight: 700;
}
.nav-link:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 4px;
}
The current-page state is driven by meaningful HTML (aria-current), while hover and focus are actual interaction states. No JavaScript-only class is required.
Worked example: selector debugging by narrowing the question
HTML:
<section class="catalog">
<article class="product featured">
<h2>Keyboard</h2>
<button disabled>Add to cart</button>
</article>
</section>
Try these one at a time:
.product {} /* matches article */
.catalog .product {} /* matches descendant */
.catalog > .product {} /* matches direct child */
.product.featured {} /* same element has both classes */
.product button:disabled {} /* disabled button inside product */
.product:has(button:disabled) {} /* product containing disabled button */
When a selector fails, do not keep adding ancestors. Ask:
- Does the rightmost simple selector match anything?
- Is the relationship (
>,+,~, or descendant) actually true? - Is the state currently true?
- Is the attribute value exactly what the selector expects?
- Is the rule matching but losing in the cascade?
That separates matching problems from cascade problems.
Tiered exercises
Checkpoint: choose the narrowest stable hook
For each visual requirement, ask what fact should make an element eligible. “Every level-two heading” suggests h2; “every reusable project panel” suggests .project; “the navigation destination representing this page” suggests [aria-current="page"]; “a link inside any depth of a project” suggests .project a. Do not add markup hooks until existing semantics and classes have been considered.
Compare .portfolio main section article.project a with .project-link. Both may match today, but the first encodes five DOM assumptions and accumulates specificity. If a wrapper changes, the rule breaks. The class says that this element has a stable component role. Conversely, assigning a class to every ordinary paragraph can produce unnecessary markup when a scoped type selector such as .project p expresses the real relationship.
Practice selector reading with a three-step method: identify the rightmost subject, move left through each combinator, then list each condition on the same element. For nav [aria-current="page"], the subject has the exact attribute and must be a descendant of nav. For .notice > p:first-child, the subject is a first-child paragraph whose direct parent has class notice. Draw a tiny DOM tree when the relationship is unclear.
Before keeping a selector, add another component instance and one unexpected wrapper. A robust selector continues matching the intended role and nothing else. This tiny mutation test catches accidental dependence on position, nesting depth, and one-off IDs before the stylesheet grows.
Foundation: Style all h2 and h3 elements together. Give every .project a border and only .featured projects a different background.
Core: Style direct project headings, all project links, external HTTPS links, the current navigation link, hover, and keyboard focus.
Stretch: Create base .notice styling plus info and success variants. Remove the first and last child margins without adding extra classes.
h2,
h3 { color: rgb(30 58 138); }
.project { padding: 1rem; border: 1px solid rgb(148 163 184); }
.project.featured { background: rgb(239 246 255); }
.project > h3 { margin-block-start: 0; }
.project a { font-weight: 700; }
[href^="https"] { text-decoration-style: double; }
[aria-current="page"] { font-weight: 800; }
a:hover { text-decoration-thickness: 0.2em; }
a:focus-visible { outline: 3px solid rgb(234 88 12); outline-offset: 3px; }
.notice { padding: 1rem; border-inline-start: 0.35rem solid rgb(71 85 105); }
.notice--info { border-color: rgb(37 99 235); background: rgb(239 246 255); }
.notice--success { border-color: rgb(21 128 61); background: rgb(240 253 244); }
.notice > :first-child { margin-block-start: 0; }
.notice > :last-child { margin-block-end: 0; }
Recap and exit questions
Selectors are DOM search patterns. Prefer reusable classes, use attributes when they express genuine state, and choose combinators based on relationships rather than visual appearance.
- What does the space mean in
.project a? - How does
.project.featureddiffer from.project .featured? - Why are classes usually preferable to IDs for styling?
- Which pseudo-class should visibly support keyboard navigation?
- Read
.notice > :first-childin plain English.
