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.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
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.
| Selector | Specificity |
|---|---|
p | 0, 0, 1 |
.card | 0, 1, 0 |
.card p | 0, 1, 1 |
[type="email"] | 0, 1, 0 |
#chart | 1, 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.
:notand:istake the specificity of their most specific argument;:wheretakes none.- Pseudo elements use two colons:
::before,::after. - Generated content from
::beforeand::afteris 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
:focusoutlines with no:focus-visiblereplacement. - Confusing
:first-childwith:first-of-type. - Putting meaningful text in
content. - Reaching for JavaScript to add a class to a parent, where
:haswould 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
:wherefor resets so nothing has to fight them. - Prefer
:focus-visiblefor focus rings. - Write semantic markup and let the element names do the selecting.
- Use
:hasinstead of a script that adds a parent class.
Practice
- Style external links and PDF links using attribute selectors only.
- Zebra stripe a table with
:nth-childand no classes. - Use
:hasto style a card differently when it contains an image. - Remove a focus outline, then add
:focus-visibleand compare mouse and keyboard behaviour.