Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close