CSS Selectors Your Markup Makes Possible

The structure you write decides which selectors are available. Attribute selectors, sibling combinators and structural pseudo classes can replace a surprising number of extra classes.

Concept

Selectors match markup. The elements you choose, the attributes you set and the way you nest them all decide how much CSS you can write without adding a single extra class.

The basic set

p                 { }   /* every paragraph */
.card             { }   /* class */
#chart            { }   /* id */
*                 { }   /* everything, rarely useful */
h2, h3, h4        { }   /* a list */

Combinators

article p         { }   /* descendant: any p inside an article */
article > p       { }   /* child: only a direct child */
h2 + p            { }   /* adjacent sibling: the p immediately after an h2 */
h2 ~ p            { }   /* general sibling: every p after an h2, same parent */

The adjacent sibling combinator is more useful than it looks. This is a classic:

/* spacing between blocks, with no leading margin at the top */
* + * { margin-top: 1rem; }

/* tighten the gap after a heading */
h2 + p { margin-top: 0.5rem; }

Attribute selectors

These remove a great many otherwise pointless classes.

[required]                 { }   /* has the attribute */
[type="email"]             { }   /* exact value */
[class~="card"]            { }   /* one of a space separated list */
[href^="https://"]         { }   /* starts with */
[href$=".pdf"]             { }   /* ends with */
[href*="example.com"]      { }   /* contains */
[lang|="en"]               { }   /* en, or en followed by a hyphen */
[data-status="paid" i]     { }   /* case insensitive */

Real uses:

/* mark external links */
a[href^="http"]:not([href*="example.edu"])::after {
  content: " ↗";
}

/* mark downloads with their format */
a[href$=".pdf"]::after { content: " (PDF)"; }

/* required fields, with no extra class */
input[required] + label::after { content: " *"; color: #dc2626; }

/* state driven by a data attribute */
[data-status="pending"] { border-left: 3px solid #f59e0b; }
[data-status="paid"]    { border-left: 3px solid #16a34a; }

Structural pseudo classes

li:first-child        { }
li:last-child         { }
li:only-child         { }
li:nth-child(3)       { }
li:nth-child(odd)     { }
li:nth-child(2n+1)    { }
li:nth-last-child(2)  { }
p:first-of-type       { }
:empty                { }
:not(.excluded)       { }
/* zebra striping with no classes in the markup */
tbody tr:nth-child(even) { background: #f8fafc; }

/* no border under the last item */
.list li:not(:last-child) { border-bottom: 1px solid #e2e8f0; }

/* the first paragraph of an article as a lead */
article > p:first-of-type { font-size: 1.125rem; }

:first-child and :first-of-type differ in a way that catches people out: the first matches only if the element is the very first child of its parent, the second matches the first of that element type regardless of what came before.

Form state pseudo classes

input:focus            { }
input:focus-visible    { }   /* focused via keyboard */
input:disabled         { }
input:checked          { }
input:required         { }
input:valid            { }
input:invalid          { }
input:user-invalid     { }   /* only after interaction */
input:placeholder-shown{ }
/* show the outline for keyboard users, not on mouse click */
button:focus { outline: none; }
button:focus-visible { outline: 3px solid #1e3a8a; outline-offset: 2px; }

/* style a label based on its checkbox */
input:checked + label { font-weight: 600; }

:focus-visible is the correct answer to the focus ring looks wrong when clicking. Never remove :focus styling without providing :focus-visible styling.

Modern selectors worth knowing

/* :is - shorten a long list */
:is(h1, h2, h3, h4) + p { margin-top: 0.5rem; }

/* :where - same, but contributes zero specificity */
:where(article, aside) a { text-decoration: underline; }

/* :has - style a parent based on its children */
label:has(input[type="checkbox"]) { display: flex; gap: 0.6rem; }
.card:has(img) { padding-top: 0; }
form:has(:user-invalid) .submit-note { display: block; }

:has is the parent selector CSS lacked for twenty years. It is now supported across current browsers and removes a whole category of JavaScript that existed only to add a class to a parent.

Writing markup that selectors can work with

<!-- flat, hard to select without adding classes everywhere -->
<div class="row">
  <span class="label">Duration</span>
  <span class="value">Three years</span>
</div>

<!-- meaningful, and selectable without any classes -->
<dl class="spec">
  <div><dt>Duration</dt><dd>Three years</dd></div>
</dl>
.spec dt { font-weight: 600; }
.spec dd { margin: 0; }

Semantic markup is easier to style, not harder, because the element names are already meaningful selectors.

Specificity, briefly

Read it as three numbers: ids, then classes and attributes and pseudo classes, then elements.

SelectorSpecificity
p0, 0, 1
.card0, 1, 0
.card p0, 1, 1
[type="email"]0, 1, 0
#chart1, 0, 0
:where(...)0, 0, 0

Higher wins. Equal specificity means the later rule wins.

Important rules

  • Selectors read right to left in the browser; the rightmost part is matched first.
  • Class and attribute selectors have equal specificity.
  • :not and :is take the specificity of their most specific argument; :where takes none.
  • Pseudo elements use two colons: ::before, ::after.
  • Generated content from ::before and ::after is announced inconsistently by screen readers - never put essential information there.

Common mistakes

  • Adding a class for something an attribute selector already matches.
  • Deep descendant chains that break when the markup changes.
  • Removing :focus outlines with no :focus-visible replacement.
  • Confusing :first-child with :first-of-type.
  • Putting meaningful text in content.
  • Reaching for JavaScript to add a class to a parent, where :has would do.

Best practices

  • Keep selectors shallow - one or two levels is usually enough.
  • Use attribute selectors for state that already exists in the markup.
  • Use :where for resets so nothing has to fight them.
  • Prefer :focus-visible for focus rings.
  • Write semantic markup and let the element names do the selecting.
  • Use :has instead of a script that adds a parent class.

Practice

  1. Style external links and PDF links using attribute selectors only.
  2. Zebra stripe a table with :nth-child and no classes.
  3. Use :has to style a card differently when it contains an image.
  4. Remove a focus outline, then add :focus-visible and compare mouse and keyboard behaviour.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.