07 · CSS Syntax & Selectors¶
CSS has one core idea: select some elements, then declare values for their properties. Everything else — the cascade, layout, animation — builds on that. This lesson covers the "select" half thoroughly, because precise selectors are what keep a stylesheet small and predictable.
Anatomy of a rule¶
/* selector declaration block */
.recipe h2 {
color: #b3401a; /* declaration: property: value; */
margin-block: 1.5rem 0.5rem;
}
- The selector (
.recipe h2) chooses elements. - Each declaration sets one property. The semicolon separates declarations; the last one can omit it, but always writing it avoids bugs when you add a line.
- An invalid declaration is ignored on its own — the rest of the rule still applies. An invalid selector makes the whole rule be ignored (with an exception for forgiving selectors, lesson Level 3 · 07).
- Comments are
/* ... */. There are no//comments in CSS.
Several selectors can share one block, separated by commas:
Three places CSS can live¶
<!-- 1. External stylesheet: the normal choice -->
<link rel="stylesheet" href="styles.css">
<!-- 2. A <style> element in the document -->
<style>
.notice { background: #fff3cd; }
</style>
<!-- 3. The style attribute on one element -->
<p style="color: red">Avoid this except for quick experiments.</p>
External files are cached across pages and keep content and presentation apart. Inline
style attributes override almost everything (lesson 08) and can't use media queries or
pseudo-classes, which makes them hard to manage.
Basic selectors¶
| Selector | Matches |
|---|---|
p |
Every <p> (type selector) |
.note |
Every element with note in its class list |
#main |
The element with id="main" |
* |
Every element |
p.note |
<p> elements with class note (no space = same element) |
.card.featured |
Elements with both classes |
The class attribute holds a space-separated list: <article class="card featured">
matches .card, .featured and .card.featured. Classes are your main tool; IDs are
unique per page and have very high specificity (lesson 08), so most teams avoid them for
styling.
Attribute selectors¶
| Selector | Matches when the attribute… |
|---|---|
[disabled] |
exists |
[type="email"] |
equals exactly |
[href^="https"] |
starts with |
[href$=".pdf"] |
ends with |
[href*="example"] |
contains |
[class~="note"] |
contains the whole word (how .note works internally) |
[type="email" i] |
equals, case-insensitively |
(There's also a rarer "dash-match" form, [lang|="en"], which matches en or anything
starting with en-.)
Attribute selectors are great for styling based on state or data that's already in the markup:
a[href$=".pdf"]::after { content: " (PDF)"; }
a[target="_blank"]::after { content: " ↗"; }
input[type="checkbox"], input[type="radio"] { accent-color: #b3401a; }
Combinators¶
Combinators select elements by their relationship to others:
| Combinator | Example | Matches |
|---|---|---|
| descendant (space) | article p |
any p inside an article, at any depth |
child > |
article > p |
p whose parent is an article |
next sibling + |
h2 + p |
the p immediately after an h2 |
subsequent sibling ~ |
h2 ~ p |
every p after an h2 with the same parent |
Pseudo-classes: state and position¶
Pseudo-classes start with one colon and select by state or position in the tree.
User-interaction state:
a:hover { text-decoration-thickness: 2px; }
a:focus-visible { outline: 3px solid #1a73e8; outline-offset: 2px; }
button:active { transform: translateY(1px); }
a:visited { color: #6a3d9a; }
:focus-visible matches when the browser decides a focus indicator should be shown —
keyboard focus yes, mouse click on a button usually not. Use it for focus styles instead
of :focus, and never remove the outline without replacing it.
Form state: :checked, :disabled, :required, :invalid, :user-invalid,
:placeholder-shown.
Tree position:
| Pseudo-class | Matches |
|---|---|
:first-child, :last-child, :only-child |
position among all siblings |
:nth-child(2), :nth-child(odd), :nth-child(3n + 1) |
by formula |
:first-of-type, :nth-of-type(2) |
position among siblings of the same element type |
:not(.done) |
elements not matching the argument |
:empty |
no children, not even text |
Pseudo-elements: parts of an element¶
Pseudo-elements use two colons and style something that isn't a separate element in the DOM:
p::first-line { font-variant: small-caps; }
p::first-letter { font-size: 2em; float: left; }
::selection { background: #ffd8a8; }
input::placeholder { color: #666; }
li::marker { color: #b3401a; }
.required::after {
content: " *";
color: #b00020;
}
::before and ::after create a generated box as the first/last child of the element;
they only render if content is set (even to ""). Generated text isn't reliably
announced by all assistive tech and can't be selected, so don't put essential information
there.
Worked example: counting matches¶
We loaded this page in Chromium and counted document.querySelectorAll(selector).length
for each selector — the same matching engine CSS uses:
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/docs/intro">Intro</a></li>
<li><a href="https://github.com/x" target="_blank">GitHub</a></li>
<li><a href="/guide.pdf">PDF</a></li>
</ul>
</nav>
<article>
<h2>T</h2>
<p>first</p>
<p>second</p>
<div><p>nested</p></div>
<p>third</p>
</article>
nav a 4
a[href^="http"] 1
a[href$=".pdf"] 1
a[target="_blank"] 1
article p 4
article > p 3
h2 + p 1
h2 ~ p 3
article p:first-of-type 2
li:nth-child(odd) 2
p:not(:first-of-type) 2
li:last-child a 1
The interesting rows:
article pis 4 butarticle > pis 3 — the "nested" paragraph is a grandchild.h2 ~ pis 3, not 4: sibling combinators only look at the same parent, and "nested" lives inside thediv.article p:first-of-typeis 2, which surprises most people. It matches everypinside the article that is the firstpamong its own siblings: "first" (firstpchild ofarticle) and "nested" (firstpchild of thediv). The pseudo-class describes the element's position in its parent, not in the selector.p:not(:first-of-type)is 2: "second" and "third".
When a selector matches more or fewer elements than expected, pasting it into
document.querySelectorAll() in the console is the fastest way to debug.
How It Actually Works¶
Browsers match selectors right to left. For nav ul li a, the engine doesn't find
every nav and search downward. It takes each element that could be styled, checks the
rightmost part (the "key selector", here a) first, and only then walks up the
ancestors looking for li, ul and nav. Most elements fail on the key selector
immediately, which makes this fast.
Engines add more shortcuts on top: rules are bucketed by their key selector's id, class
or tag, so an <a> element only gets checked against rules whose key is a, a class it
has, or *. And a Bloom filter of ancestor ids/classes/tags lets the engine reject
nav ul li a for an element with no nav ancestor without walking the tree.
This is why old advice about "expensive selectors" is mostly obsolete for normal
stylesheets — matching is rarely the bottleneck. What can cost more is selectors that
force re-matching many elements when something changes, like :has() on a large subtree
(Level 3 · 07) — and even that is heavily optimised in current engines.
Pseudo-elements don't exist in the DOM; they exist in the box tree that layout builds
from the DOM. That's why you can't select them with querySelector or attach event
listeners to them.
Common mistakes¶
- Space vs no space:
p .note(a.noteinside ap) vsp.note(apwith classnote). - Expecting
:first-of-typeto mean "the first one this selector finds." - Forgetting
contenton::before/::after, so nothing appears. - Using ids for styling — then struggling to override them (lesson 08).
- Removing focus outlines (
outline: none) with no replacement. - Deep chains like
.header .nav ul li a span, which break when markup changes. Target a class on the element you mean.
Exercise¶
- Style your recipe page: body font and max-width; headings in one colour;
h2 + pwith a slightly larger font; ingredient list markers coloured withli::marker. - Make PDF links show "(PDF)" and external links show "↗" using attribute selectors and
::after. - Style method steps so every other step has a light background with
:nth-child. - Add visible
:focus-visiblestyles for links and buttons. Tab through the page to check them. - Predict, then check in the console, how many elements
li:first-of-typematches on your page. Explain the number.