FullStack Course LogoFullStack Course
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

Start with yesterday's portfolio and its external stylesheet. First, identify the selector, property, and value in .project { color: navy; }. Then predict what happens if a second element receives class="project". You do not need another rule: a selector describes a set of matching elements, not one particular element.

Terminology

Mental model: search patterns over a tree

Think of the DOM as a family tree and a selector as a query over that tree, not as the name of a declaration block. Complex selectors are easiest to reason about from right to left. .project > h3 asks for each h3 whose parent is .project; .project a asks for each a at any depth inside .project.

Classes are the usual reusable styling hook. A class can appear on many elements, and one element can carry several classes. IDs still have good uses as fragment destinations, labels, and unique programmatic identifiers, but their higher CSS specificity makes later overrides harder. When either would style the component, prefer .contact to #contact.

Here are the main 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 is meaningful. .project.featured requires both classes on one element, while .project .featured looks for a .featured descendant inside a .project.

Beginner example: selector laboratory

Add this markup 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>

Now apply these selectors one at a time:

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 the matching elements before you reload. The first three headings are covered by the selector list. Both articles match .project, but only the first matches .project.featured. The child selector reaches only direct h3 children; the descendant selector finds links at any depth. [href^="https"] means that the href value begins with https. State pseudo-classes respond to the current interaction state, so the HTML does not need extra classes for them.

Test the links with Tab as well as with the mouse. :hover and :focus-visible represent different input states and are not interchangeable.

Intermediate example: reusable component variants

Build two notification components from one base class:

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 the common design, while each variant changes only its differences. :first-child and :last-child are structural pseudo-classes. In .notice > :first-child, the subject is the child matched by :first-child, not .notice. That relationship avoids depending on a particular number of wrapper elements.

Optional advanced example: attribute semantics

Form controls already expose useful state through their HTML 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 genuine HTML state, but do not make CSS the only way that state is communicated. The required and disabled semantics are still available to browsers and assistive technology.

Mistakes, debugging, and DevTools

  • Forgetting . or #: project targets an element named project; .project targets a class.
  • Adding a space accidentally: .notice --info does not match .notice--info.
  • Confusing child and descendant: > allows exactly one parent step; a space allows any depth.
  • Reusing IDs: every id must be unique in the document.
  • Invalid selector lists: in an ordinary comma-separated list, one invalid selector can invalidate the whole rule.
  • Styling every link from a partial URL without checking exceptions: inspect which elements actually match.
  • Designing only for :hover: keyboard and touch users may never hover.

Inspect an element and read “Matched CSS Rules.” DevTools often displays selector specificity and highlights the matched portion. In the Console, document.querySelectorAll('.project > h3') confirms the matching set, although you do not need JavaScript knowledge to use the Styles panel. Temporarily add outline: 3px solid magenta; to visualize the elements a rule targets.

Accessibility and performance

Selectors do not change semantics. Styling a <div> does not turn it into a button, so start with semantic HTML. Keep focus visible. Do not use display: none simply to make important text visually subtle: it removes that content from rendering and from the accessibility tree. Also avoid communicating meaning only with .success in green and .error in red; include words, suitable text alternatives for icons, or other cues.

Modern browsers match ordinary selectors efficiently. In practice, readability and maintainability are more useful concerns than selector micro-optimization. Avoid very long chains that depend on every wrapper. A component class better survives markup changes and keeps specificity under control.

Deep dive: selector families and combinators

Selectors become easier to reason about when you group them into families instead of memorizing punctuation one mark at a time.

Simple selectors

css
* {}                       /* universal */
p {}                       /* type */
.card {}                   /* class */
#checkout {}               /* ID */
[disabled] {}              /* attribute presence */
[type="email"] {}          /* exact attribute value */

For component styling, prefer stable classes. IDs are valid selectors, but they are usually more specific than a reusable visual rule needs to be.

Combinators describe relationships

Given this structure:

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 these relationships:

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 carry the meaning. .card > p is not “more specific” because it contains >; it simply describes a different relationship.

Deep dive: pseudo-classes as state and structure queries

A pseudo-class matches an element through state, position, or a relationship that is not represented 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 to provide a strong keyboard focus treatment without assuming that every pointer click needs the same visible ring:

css
:where(a, button, input, select, textarea):focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 3px;
}

Never remove focus outlines unless an equally visible replacement is provided.

Functional selectors: :is(), :where(), :not(), and :has()

Without grouping:

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

The same matching idea can be expressed with :is():

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

:where() matches in a similar way, 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() lets an element respond to matching descendants or relatives:

css
.form-field:has(input:invalid) {
  border-color: #b91c1c;
}

Use :has() when it expresses a real structural relationship. It should not replace clear component classes everywhere.

Deep dive: pseudo-elements style generated or partial boxes

Pseudo-elements target part of an element or create 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. It may not be exposed consistently to assistive technologies, and it is not present in 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 comes from meaningful HTML, aria-current, while hover and focus represent actual interaction states. No JavaScript-only class is needed.

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 selectors 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, narrow the question instead of continually adding ancestors:

  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 process separates a matching problem from a cascade problem.

Tiered exercises

Checkpoint: choose the narrowest stable hook

For each visual requirement, identify the fact that 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. Before adding a new markup hook, check whether existing semantics and classes already express the requirement.

Compare .portfolio main section article.project a with .project-link. Both may match the current document, but the first encodes five DOM assumptions and accumulates specificity. Change a wrapper and it can stop matching. The class communicates a stable component role. On the other hand, adding a class to every ordinary paragraph can create needless markup when a scoped type selector such as .project p already expresses the relationship.

Practice reading selectors in three steps: identify the rightmost subject, move left across each combinator, then list the conditions applied to that same element. In nav [aria-current="page"], the subject has the exact attribute and must be a descendant of nav. In .notice > p:first-child, the subject is a first-child paragraph whose direct parent has class notice. If the relationship is unclear, draw a small DOM tree.

Before you keep a selector, add another component instance and insert one unexpected wrapper. A robust selector should continue matching the intended role and nothing else. This small mutation test exposes accidental dependence on position, nesting depth, or one-off IDs before the stylesheet becomes difficult to change.

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 search patterns over the DOM. Prefer reusable classes, use attributes when they represent genuine state, and choose combinators from the relationship you mean rather than from the way the elements happen to look.

  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

Reader page: /css/lesson/019/selectors