Module: CSS
CSS·019·8 MIN READ

019: Selectors

TOPICS COVERED: 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

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:

css
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:

html
<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:

css
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:

html
<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>
css
.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:

html
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<button type="submit" disabled>Send</button>
css
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 #: project targets a nonexistent element; .project targets a class.
  • Adding a space accidentally: .notice --info is unrelated to .notice--info.
  • Confusing child and descendant: > permits exactly one parent step; a space permits any depth.
  • Reusing IDs: each id must 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

css
* {}                       /* 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:

html
<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:

css
.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.

css
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:

css
: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:

css
.card h2,
.card h3,
.card h4 {
  line-height: 1.2;
}

With :is():

css
.card :is(h2, h3, h4) {
  line-height: 1.2;
}

:where() matches similarly but contributes zero specificity:

css
:where(.prose h2, .prose h3, .prose h4) {
  margin-block-start: 1.5em;
}

:not() excludes matches:

css
.button:not(.button--primary) {
  background: transparent;
}

:has() allows a subject to react to matching descendants or relatives:

css
.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:

css
.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

html
<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>
css
.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:

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:

css
.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:

  1. Does the rightmost simple selector match anything?
  2. Is the relationship (>, +, ~, or descendant) actually true?
  3. Is the state currently true?
  4. Is the attribute value exactly what the selector expects?
  5. 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.

css
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.

  1. What does the space mean in .project a?
  2. How does .project.featured differ from .project .featured?
  3. Why are classes usually preferable to IDs for styling?
  4. Which pseudo-class should visibly support keyboard navigation?
  5. Read .notice > :first-child in plain English.

Official references