Lesson 04 / 26
Combinator Selectors
The descendant, child, adjacent-sibling, and general-sibling combinators; a selector's right-to-left evaluation and the key-selector concept.
Contents
The previous lesson’s last rule wrote .masthead .summary, and it was said in passing that
the space between the two selectors is an operator. That space is called a
combinator, and it adds a question of position in the tree to the selector: not the
element itself, but where it stands is asked.
This lesson defines the four combinators and shows how a selector is evaluated. The evaluation order matters for understanding both the result and a selector’s cost.
Four Combinators
| Notation | Name | Question asked |
|---|---|---|
A B |
descendant combinator | Is one of B’s ancestors A? |
A > B |
child combinator | Is B’s direct parent A? |
A + B |
adjacent sibling | Is B’s immediately preceding sibling A? |
A ~ B |
general sibling | Is one of B’s preceding siblings A? |
All four are read the same way: the right side is selected, the left side is the
condition. The rule .measurement-table td selects td elements;
.measurement-table only narrows down which td elements get selected. The left side is
never styled.
Sibling combinators only look backward. The notation A + B means “B that follows A”;
there is no combinator for “A that follows B.”
Evaluation Happens from Right to Left
A selector is read left to right but evaluated right to left. The reason is a difference in cost between the two directions.
If evaluation went left to right, for .measurement-table td every element carrying that
class would first be found across the whole document, then its entire subtree would be
scanned for each one. Going right to left, for every td element only the ancestor chain
is followed upward; the length of that chain is the depth of the tree. This second
direction is also the natural direction of the question asked when an element’s style is
computed: “which rules match this element?”
The compound selector on the right is called the key selector. It is what determines which elements a rule is tested against.
The following program evaluates the four combinators from right to left. The tree carries
the measurement table’s real structure: rows are grouped inside thead and tbody.
// combinator.mjs — evaluates four combinators from right to left const tree = { name: "body", classNames: [], children: [ { name: "main", classNames: [], children: [ { name: "table", classNames: ["measurement-table"], children: [ { name: "thead", classNames: [], children: [ { name: "tr", classNames: [], children: [{ name: "th", classNames: [], children: [] }] }, ]}, { name: "tbody", classNames: [], children: [ { name: "tr", classNames: [], children: [ { name: "td", classNames: ["name"], children: [] }, { name: "td", classNames: ["value"], children: [] }, { name: "td", classNames: ["value", "missing"], children: [] }, ]}, ]}, ]}, { name: "p", classNames: ["note"], children: [] }, { name: "table", classNames: [], children: [ { name: "tbody", classNames: [], children: [ { name: "tr", classNames: [], children: [{ name: "td", classNames: ["value"], children: [] }] }, ]}, ]}, ]}, ]}; // add parent and sibling info to every node function attach(n, parent = null) { n.parent = parent; n.children.forEach((c, i) => { c.previousSibling = i > 0 ? n.children[i - 1] : null; attach(c, n); }); return n; } attach(tree); const path = (n) => (n.parent ? path(n.parent) + " " : "") + n.name + n.classNames.map((s) => "." + s).join(""); function* all(n) { yield n; for (const c of n.children) yield* all(c); } const simpleMatches = (s, n) => s === "*" ? true : s.startsWith(".") ? n.classNames.includes(s.slice(1)) : n.name === s; // compound selector: simple selectors with no space between them function compoundMatches(compound, n) { const parts = compound.match(/(^[a-z]+|\.[a-z-]+|\*)/g) ?? []; return parts.every((p) => simpleMatches(p, n)); } // selector: compound (combinator compound)* — resolved from right to left function selectorMatches(selector, node) { const tokens = selector.trim().split(/\s+/); let n = node; let i = tokens.length - 1; if (!compoundMatches(tokens[i], n)) return false; i--; while (i >= 0) { const combinator = ">+~".includes(tokens[i]) ? tokens[i--] : " "; const target = tokens[i--]; if (combinator === ">") { n = n.parent; if (!n || !compoundMatches(target, n)) return false; } else if (combinator === "+") { n = n.previousSibling; if (!n || !compoundMatches(target, n)) return false; } else if (combinator === "~") { let k = n.previousSibling; while (k && !compoundMatches(target, k)) k = k.previousSibling; if (!k) return false; n = k; } else { let a = n.parent; while (a && !compoundMatches(target, a)) a = a.parent; if (!a) return false; n = a; } } return true; } const trials = [ "table td", ".measurement-table td", ".measurement-table > td", "tbody > tr > td", "td.name + td", "td.name ~ td", ".measurement-table + p", ]; for (const s of trials) { const found = [...all(tree)].filter((n) => selectorMatches(s, n)); console.log(`${s.padEnd(26)} -> ${found.length}`); for (const f of found) console.log(` ${path(f)}`); }
table td -> 4 body main table.measurement-table tbody tr td.name body main table.measurement-table tbody tr td.value body main table.measurement-table tbody tr td.value.missing body main table tbody tr td.value .measurement-table td -> 3 body main table.measurement-table tbody tr td.name body main table.measurement-table tbody tr td.value body main table.measurement-table tbody tr td.value.missing .measurement-table > td -> 0 tbody > tr > td -> 4 body main table.measurement-table tbody tr td.name body main table.measurement-table tbody tr td.value body main table.measurement-table tbody tr td.value.missing body main table tbody tr td.value td.name + td -> 1 body main table.measurement-table tbody tr td.value td.name ~ td -> 2 body main table.measurement-table tbody tr td.value body main table.measurement-table tbody tr td.value.missing .measurement-table + p -> 1 body main p.note
This implementation does not backtrack on the descendant and general-sibling steps: it picks the first matching ancestor or the first matching sibling and stays there. A real matcher backtracks and tries another ancestor if the next step fails. In the seven examples above, both behaviors give the same result.
A Selector That Returns Zero Elements
The third line calls for attention: .measurement-table > td returned no element, while
.measurement-table td returned three. The only difference between them is one character.
The reason is in the tree. Cells are not the table’s direct children; the chain between
them is table > tbody > tr > td. The child combinator demands exactly one step, and that
step falls on the tbody element.
The Web Fundamentals and HTML course described how the parser builds elements not written
in the source; tbody is the most commonly encountered case of this. Even if the tbody
tag was not written in the source text, the element exists in the document, and a selector
sees that element. This is the style side’s first concrete consequence of the “tag” versus
“element” distinction: a selector does not look at the source text, it looks at the tree
after parsing.
When the Child Combinator Is Needed
The descendant combinator does not care about depth, and that is the desired behavior most of the time. But it gives an unwanted result in structures nested inside the same kind of structure.
If the station page’s navigation section carries nested lists, the selector .nav li
returns both the top-level items and the nested ones. To target only the top level,
.nav > ul > li is written. The rule is: if depth carries meaning, the child combinator is
used; if it does not, the descendant combinator is used.
Sibling Combinators Depend on Order
td.name + td returned one cell: the value cell immediately following the name cell.
td.name ~ td returned both, because the general-sibling combinator does not care how
many siblings come between them.
The typical use of sibling combinators is styling the relationship between two elements. To put space between two consecutive paragraphs:
p + p { margin-block-start: 0.75em; }
This notation does not add space before the first paragraph of a section — because it has no preceding paragraph sibling. The same result could also be reached by giving every paragraph space and then removing it from the first one; the form written with a sibling combinator keeps the rule in a single place, with no exception.
The Station Page’s Rules
The style file starts using tree positions with this lesson:
/* station.css — step 3: relationship-based rules */ .measurement-table tbody td { padding: 8px 12px; } .measurement-table tbody > tr { border-block-end: 1px solid #d5dbe0; } .nav > ul > li { display: inline-block; } .measurement-table + p { font-size: 0.875rem; }
The last rule shrinks the caption paragraph that comes right after the table. Giving the paragraph a separate class would give the same result; the difference is whether the rule requires adding a class to the document. A style problem that can be solved without touching the document is solved in the style file.
Summary
- A combinator adds a question of tree position to a selector; all four are read the same way — the right side is selected, the left side is the condition.
- The descendant combinator does not care about depth; the child combinator demands exactly one step; the adjacent sibling looks at the immediately preceding sibling, the general sibling at any of the preceding siblings.
- A selector is evaluated right to left; the rightmost compound selector is the key selector, and it determines which elements a rule is tested against.
- A selector looks at the tree after parsing, not the source text; elements not written in
the source, such as
tbody, are part of the chain and affect the child combinator. - Sibling combinators only look backward; there is no combinator for “the one before the next sibling.”
Next Step
Selectors have so far looked at an element’s name, class, id, and position in the tree. There is one more piece of information in the document, and it has not been used yet: attribute values. A form field’s type, a link’s target address, the measurement units attached with data attributes — all of it sits in attributes. The next lesson defines attribute selectors and their value-matching forms.
To keep your progress and take notes, Log in
My notes
Log in to take notes.