---
title: 'Combinator Selectors'
source: 'https://academia.sh/en/courses/css-fundamentals/combinator-selectors'
course: 'Visual Presentation with CSS'
language: en
updated: '2026-08-17T18:09:17+00:00'
license: 'CC BY-SA 4.0'
---

# Combinator Selectors

The descendant, child, adjacent-sibling, and general-sibling combinators; a selector's right-to-left evaluation and the key-selector concept.

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`.

```js
// 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:

```css
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:

```css
/* 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.
