Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 2 – Selectors

Type, class, ID, attribute and combinators

Select elements by type, class, ID and attribute, join selectors with the four combinators, and see why one bad selector drops a whole list.

  • Beginner
  • 30 minutes
  • Examples run with Node.js 24.21.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Select elements by type, class, ID and attribute, including the ^=, $=, *=, ~= and |= operators and the i flag
  • Combine selectors with the descendant, child, next-sibling and subsequent-sibling combinators
  • Group selectors in a list and predict what one invalid selector does to the whole list

Before you start

What you’ll learn · Before you start

What you will learn

  • Select elements by type, class, ID and attribute, including the ^=, $=, *=, ~= and |= operators and the i flag
  • Combine selectors with the descendant, child, next-sibling and subsequent-sibling combinators
  • Group selectors in a list and predict what one invalid selector does to the whole list

Before you start

On this page

A selector is the part of a CSS rule in front of the braces. It names the elements that the rule’s declarations apply to: li picks every list item, .sale every element with the class sale, #email the one element whose ID is email, and a[href$=".pdf"] every link that ends in .pdf. Those four kinds, type, class, ID and attribute, describe one element. Combinators join them to describe where an element sits relative to another: article p is any paragraph inside an article, h2 + p the paragraph right after a heading. A comma groups several selectors into a selector list that shares one set of declarations.

Getting selectors right matters because a browser never warns you about a selector that matches nothing, and one broken selector in a list silently switches off the whole rule. This lesson runs every selector against one small page and prints exactly which elements each one matches.

One page for every example

All the selectors in this lesson run against the page below: a market’s weekly listing with navigation, a few paragraphs, a product list, links to two files, four sections in other languages and a sign-up form.

page.html, the page the selectors run against HTML · lab/page.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Saturday market</title>
</head>
<body>
  <nav aria-label="Main">
    <a href="/" aria-current="page">Home</a>
    <a href="/stalls">Stalls</a>
    <a href="https://maps.example.org/saturday-market">Directions</a>
  </nav>
  <article class="listing">
    <h2>Fresh this week</h2>
    <p class="lead">Fruit and vegetables from nearby farms.</p>
    <div class="box">
      <p>All prices include tax.</p>
    </div>
    <p class="note">Stalls open at 7 and close at noon.</p>
    <ul class="products">
      <li data-tags="fruit seasonal">Mangoes</li>
      <li class="sale" data-tags="fruit sale">Bananas</li>
      <li data-tags="vegetable">Okra</li>
      <li class="sale" data-tags="dairy sale">Paneer</li>
      <li data-tags="grain wholesale">Rice</li>
    </ul>
    <footer>
      <a href="/files/price-list.pdf">Price list</a>
      <a href="/files/Stall-Map.PDF">Stall map</a>
    </footer>
  </article>
  <section lang="hi">ताज़ी सब्ज़ियाँ</section>
  <section lang="hi-IN">ताज़े फल</section>
  <section lang="hi-Latn">Taazi sabziyan</section>
  <section lang="ta">புதிய காய்கறிகள்</section>
  <form class="signup">
    <label for="email">Email for the weekly list</label>
    <input id="email" type="email" name="email" required>
    <button type="submit" data-state="Ready">Join</button>
  </form>
</body>
</html>

The examples are short programs that pass a list of selectors to selector-lab.mjs, a small model of a browser’s selector matching. It reads page.html, follows the matching rules of Selectors Level 4 and the case rules of the HTML Standard, and prints each selector with the elements it matches, in document order, as querySelectorAll() returns them. Every one of the 30 selectors in this lesson matches the same elements in Chromium 153, Firefox 155 and WebKit 26.6. To check a selector yourself, open page.html in a browser and run document.querySelectorAll('li.sale') in the developer tools console.

Type, class and ID selectors

A type selector is an element name: li matches every li element. In an HTML document, element names ignore case, so LI matches the same five items, though lower case is the convention. The universal selector * matches every element.

A class selector is a dot and a class name. .sale matches every element whose class attribute contains the word sale, whatever the element’s type. An element can carry several classes, separated by spaces (class="sale featured"), and each of them can be selected on its own. Class names keep their case: .Sale does not match class="sale".

An ID selector is a hash and an ID: #email matches the element with id="email". An ID must be unique in its page, so an ID selector picks at most one element. That makes it a poor styling hook: a style written for #email can never be reused, and when two rules compete, one ID selector outweighs any number of class selectors, which makes an ID rule hard to override later. The cascade module of this track explains that weighing, called specificity. IDs remain useful as link targets and for connecting a label to its input, and the styles go on classes.

Selectors written next to each other with no space form a compound selector, which describes one element that matches every part: li.sale is an li that also has the class sale, and input#email[required] is the input with that ID that also has a required attribute. A space between them would mean something else entirely, as the section on combinators shows.

Type, class and ID selectors on page.html

lab/basics.mjs

// Type, class and ID selectors, alone and combined into compound selectors, run against page.html.
import { readPage, report } from "./selector-lab.mjs";

report(readPage("page.html"), [
  "li",
  "LI",
  ".sale",
  ".Sale",
  "li.sale",
  ".listing .sale",
  "#email",
  "input#email[required]",
]);

Output

li   5 elements
  li  Mangoes
  li.sale  Bananas
  li  Okra
  li.sale  Paneer
  li  Rice

LI   5 elements
  li  Mangoes
  li.sale  Bananas
  li  Okra
  li.sale  Paneer
  li  Rice

.sale   2 elements
  li.sale  Bananas
  li.sale  Paneer

.Sale   0 elements

li.sale   2 elements
  li.sale  Bananas
  li.sale  Paneer

.listing .sale   2 elements
  li.sale  Bananas
  li.sale  Paneer

#email   1 element
  input#email

input#email[required]   1 element
  input#email [required]

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node basics.mjs

lab/selector-lab.mjs

// A small model of how a browser matches CSS selectors, following Selectors Level 4 and the case rules of the HTML
// Standard. It reads a simple, well-formed HTML file and knows type and universal selectors, classes, IDs, the seven
// attribute forms with the i and s flags, compound selectors, the four combinators and selector lists. A selector
// that breaks the grammar is reported as invalid, the way a browser treats it. Pseudo-classes, escapes and namespaces
// are reported as not part of this lab: they are valid CSS, but this model does not read them.
import { readFileSync } from "node:fs";

// ---- the page -----------------------------------------------------------------------------------------------------

/** Elements that never have children or an end tag. */
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);

/** Reads an HTML file into elements (in document order), each with its tag, attributes, parent and children. */
export function readPage(file) {
  const html = readFileSync(file, "utf8").replace(/<!--[\s\S]*?-->/g, "").replace(/<!doctype[^>]*>/i, "");
  const root = { tag: "#document", children: [] };
  const elements = [];
  let open = root;
  const pieces = /<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^\s=>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*\/?>|([^<]+)/g;
  for (const [, endTag, tag, attributes, text] of html.matchAll(pieces)) {
    if (text !== undefined) {
      open.children.push({ text });
    } else if (endTag) {
      let n = open;
      while (n !== root && n.tag !== endTag.toLowerCase()) n = n.parent;
      if (n !== root) open = n.parent;
    } else {
      const el = { tag: tag.toLowerCase(), attrs: new Map(), parent: open, children: [] };
      for (const a of attributes.matchAll(/([^\s=>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
        // HTML attribute names ignore case.
        el.attrs.set(a[1].toLowerCase(), a[2] ?? a[3] ?? a[4] ?? "");
      }
      open.children.push(el);
      elements.push(el);
      if (!VOID.has(el.tag)) open = el;
    }
  }
  return elements;
}

const isElement = (n) => !!n && n.tag !== undefined && n.tag !== "#document";
const textOf = (n) => (n.text !== undefined ? n.text : (n.children ?? []).map(textOf).join(""));

// ---- reading a selector -------------------------------------------------------------------------------------------

/** A CSS name: it may not start with a digit, or with a hyphen followed by a digit (CSS Syntax Level 3). */
const NAME = /^(?:--|-?[A-Za-z_\u0080-\uFFFF])[\w\u0080-\uFFFF-]*/;

class Invalid extends Error {}
class NotInLab extends Error {}

/** Parses a selector list into complex selectors: [compound, combinator, compound, …]. Throws Invalid or NotInLab. */
export function parseList(text) {
  let i = 0;
  const at = () => text.slice(i);
  const skipSpace = () => {
    const start = i;
    while (/\s/.test(text[i] ?? "")) i++;
    return i > start;
  };
  const name = (what) => {
    if (text[i] === "\\") throw new NotInLab("escapes are valid CSS, but this lab does not read them");
    const m = NAME.exec(at());
    if (!m) {
      const found = /^[^\s,>+~.#[\]:]+/.exec(at())?.[0];
      if (!found) throw new Invalid(`the ${what} is missing`);
      const why = /^-?\d/.test(found) ? ": a name cannot start with a digit" : "";
      throw new Invalid(`"${found}" is not a valid ${what}${why}`);
    }
    i += m[0].length;
    return m[0];
  };
  const attribute = () => {
    skipSpace();
    const attr = { name: name("attribute name").toLowerCase(), op: null, value: null, flag: null };
    skipSpace();
    if (text[i] === "]") {
      i++;
      return attr;
    }
    const op = /^[~|^$*]?=/.exec(at())?.[0];
    if (!op) throw new Invalid(`"${at().slice(0, 2)}" is not an attribute operator`);
    i += op.length;
    attr.op = op;
    skipSpace();
    const quote = text[i];
    if (quote === '"' || quote === "'") {
      const end = text.indexOf(quote, i + 1);
      if (end < 0) throw new Invalid("the attribute value has no closing quote");
      attr.value = text.slice(i + 1, end);
      i = end + 1;
    } else {
      attr.value = name("unquoted attribute value");
    }
    skipSpace();
    const flag = /^([is])(?=[\s\]])/i.exec(at());
    if (flag) {
      attr.flag = flag[1].toLowerCase();
      i += 1;
      skipSpace();
    }
    if (text[i] !== "]") throw new Invalid(`expected "]" after the attribute value, found "${at().slice(0, 4) || "the end"}"`);
    i++;
    return attr;
  };
  const compound = () => {
    const c = { type: null, ids: [], classes: [], attrs: [] };
    if (text[i] === "*") {
      c.type = "*";
      i++;
    } else if (NAME.test(at())) {
      // HTML element names ignore case.
      c.type = name("type selector").toLowerCase();
    }
    for (;;) {
      const ch = text[i];
      if (ch === ".") {
        i++;
        c.classes.push(name("class name"));
      } else if (ch === "#") {
        i++;
        c.ids.push(name("ID"));
      } else if (ch === "[") {
        i++;
        c.attrs.push(attribute());
      } else if (ch === ":") {
        throw new NotInLab(`pseudo-classes and pseudo-elements, such as "${at().split(/[\s,>+~]/)[0]}", are valid CSS, but this lab does not read them`);
      } else if (ch === "|") {
        throw new NotInLab("namespaces are valid CSS, but this lab does not read them");
      } else break;
    }
    if (!c.type && !c.ids.length && !c.classes.length && !c.attrs.length) {
      if (i < text.length) throw new Invalid(`"${text[i]}" cannot start a selector`);
      const last = text.trimEnd().at(-1);
      throw new Invalid(last === "," ? "the list ends with a comma, so its last selector is empty" : `the selector ends with "${last}", which needs a selector after it`);
    }
    return c;
  };
  const complex = () => {
    const parts = [compound()];
    for (;;) {
      const spaced = skipSpace();
      const ch = text[i];
      if (ch === undefined || ch === ",") return parts;
      let combinator = " ";
      if (ch === ">" || ch === "+" || ch === "~") {
        combinator = ch;
        i++;
        skipSpace();
      } else if (!spaced) {
        throw new Invalid(`"${ch}" cannot follow a selector here`);
      }
      parts.push(combinator, compound());
    }
  };
  skipSpace();
  const list = [complex()];
  while (text[i] === ",") {
    i++;
    skipSpace();
    list.push(complex());
  }
  return list;
}

// ---- matching -----------------------------------------------------------------------------------------------------

/** Attributes whose values HTML compares without regard to ASCII case (HTML Standard, case-sensitivity of selectors). */
const CASELESS = new Set(
  ("accept accept-charset align alink axis bgcolor charset checked clear codetype color compact declare defer dir " +
    "direction disabled enctype face frame hreflang http-equiv lang language link media method multiple nohref " +
    "noresize noshade nowrap readonly rel rev rules scope scrolling selected shape target text type valign valuetype vlink").split(" "),
);
const asciiLower = (s) => s.replace(/[A-Z]/g, (c) => c.toLowerCase());
const words = (s) => s.split(/[ \t\n\f\r]+/).filter(Boolean);

function attributeMatches(el, a) {
  if (!el.attrs.has(a.name)) return false;
  if (a.op === null) return true;
  let have = el.attrs.get(a.name);
  let want = a.value;
  // The i flag ignores ASCII case, the s flag keeps it; without a flag, HTML decides per attribute.
  if (a.flag === "i" || (a.flag !== "s" && CASELESS.has(a.name))) {
    have = asciiLower(have);
    want = asciiLower(want);
  }
  switch (a.op) {
    case "=":
      return have === want;
    case "~=": // one of the space-separated words; a value with a space (or an empty one) never matches
      return want !== "" && !/\s/.test(want) && words(have).includes(want);
    case "|=": // exactly the value, or the value followed by a hyphen
      return have === want || have.startsWith(`${want}-`);
    case "^=": // the three substring forms never match an empty value
      return want !== "" && have.startsWith(want);
    case "$=":
      return want !== "" && have.endsWith(want);
    case "*=":
      return want !== "" && have.includes(want);
  }
  return false;
}

function compoundMatches(el, c) {
  if (c.type && c.type !== "*" && el.tag !== c.type) return false;
  const classes = words(el.attrs.get("class") ?? "");
  return c.classes.every((k) => classes.includes(k)) && c.ids.every((id) => el.attrs.get("id") === id) && c.attrs.every((a) => attributeMatches(el, a));
}

/**
 * Matching runs from the right: the last compound must match the element itself, and each combinator says where the
 * compound before it must match: on an ancestor (a space), the parent (>), the element just before it (+) or any
 * earlier sibling (~).
 */
function matchesFrom(el, parts, k) {
  if (!compoundMatches(el, parts[k])) return false;
  if (k === 0) return true;
  const combinator = parts[k - 1];
  if (combinator === ">") return isElement(el.parent) && matchesFrom(el.parent, parts, k - 2);
  if (combinator === " ") {
    for (let up = el.parent; isElement(up); up = up.parent) if (matchesFrom(up, parts, k - 2)) return true;
    return false;
  }
  const siblings = el.parent.children.filter(isElement);
  const index = siblings.indexOf(el);
  if (combinator === "+") return index > 0 && matchesFrom(siblings[index - 1], parts, k - 2);
  for (let j = index - 1; j >= 0; j--) if (matchesFrom(siblings[j], parts, k - 2)) return true;
  return false;
}

/** The elements a selector list matches, in document order, the order querySelectorAll() returns. Throws if invalid. */
export function matchAll(elements, selector) {
  const list = parseList(selector);
  return elements.filter((el) => list.some((parts) => matchesFrom(el, parts, parts.length - 1)));
}

/** A short name for an element: its tag, ID and classes, then the attributes the selector looks at, then its text. */
function describe(el, selector) {
  const id = el.attrs.has("id") ? `#${el.attrs.get("id")}` : "";
  const classes = words(el.attrs.get("class") ?? "").map((c) => `.${c}`).join("");
  const asked = [...selector.matchAll(/\[\s*([\w-]+)/g)].map((m) => m[1].toLowerCase()).filter((a) => el.attrs.has(a) && a !== "id" && a !== "class");
  const attrs = [...new Set(asked)].map((a) => (el.attrs.get(a) === "" ? ` [${a}]` : ` [${a}="${el.attrs.get(a)}"]`)).join("");
  const text = textOf(el).replace(/\s+/g, " ").trim();
  return `${el.tag}${id}${classes}${attrs}${text ? `  ${text.length > 40 ? `${text.slice(0, 39)}…` : text}` : ""}`;
}

/** Prints each selector with what it matches. */
export function report(elements, selectors) {
  for (const selector of selectors) {
    let hits;
    try {
      hits = matchAll(elements, selector);
    } catch (e) {
      const kind = e instanceof NotInLab ? "not in this lab" : "invalid";
      console.log(`${selector}\n  ${kind}: ${e.message}${kind === "invalid" ? ". The whole list matches nothing." : "."}\n`);
      continue;
    }
    console.log(`${selector}   ${hits.length} element${hits.length === 1 ? "" : "s"}`);
    for (const el of hits) console.log(`  ${describe(el, selector)}`);
    console.log("");
  }
}

li and LI match the same five items, while .Sale matches nothing. li.sale and .listing .sale find the same two items here, but for different reasons: the first asks for list items with the class, the second for anything with the class inside the article.

Attribute selectors

An attribute selector matches on an attribute that the markup already holds, so it needs no extra class. Inside the square brackets goes the attribute’s name, optionally followed by an operator and a value in quotes. Each form below matches an element whose attribute meets the condition:

  • [required]: the attribute exists, with any value or none.
  • [type="email"]: its value is exactly email.
  • [data-tags~="sale"]: its value is a list of words separated by spaces, and one of them is sale.
  • [lang|="hi"]: its value is exactly hi, or starts with hi followed by a hyphen.
  • [href^="https:"]: its value starts with https:.
  • [href$=".pdf"]: its value ends with .pdf.
  • [href*="map"]: its value contains map anywhere.
The attribute selectors on page.html JavaScript · lab/attributes.mjs
// The attribute selectors and their case rules, run against page.html.
import { readPage, report } from "./selector-lab.mjs";

report(readPage("page.html"), [
  "a[aria-current]",
  'a[href^="https:"]',
  'a[href$=".pdf"]',
  'a[href$=".pdf" i]',
  'li[data-tags~="sale"]',
  'li[data-tags*="sale"]',
  'section[lang|="hi"]',
  'section[lang="hi"]',
  'input[type="EMAIL"]',
  'button[data-state="ready"]',
  'button[data-state="ready" i]',
  'a[href*=""]',
]);

Output

a[aria-current]   1 element
  a [aria-current="page"]  Home

a[href^="https:"]   1 element
  a [href="https://maps.example.org/saturday-market"]  Directions

a[href$=".pdf"]   1 element
  a [href="/files/price-list.pdf"]  Price list

a[href$=".pdf" i]   2 elements
  a [href="/files/price-list.pdf"]  Price list
  a [href="/files/Stall-Map.PDF"]  Stall map

li[data-tags~="sale"]   2 elements
  li.sale [data-tags="fruit sale"]  Bananas
  li.sale [data-tags="dairy sale"]  Paneer

li[data-tags*="sale"]   3 elements
  li.sale [data-tags="fruit sale"]  Bananas
  li.sale [data-tags="dairy sale"]  Paneer
  li [data-tags="grain wholesale"]  Rice

section[lang|="hi"]   3 elements
  section [lang="hi"]  ताज़ी सब्ज़ियाँ
  section [lang="hi-IN"]  ताज़े फल
  section [lang="hi-Latn"]  Taazi sabziyan

section[lang="hi"]   1 element
  section [lang="hi"]  ताज़ी सब्ज़ियाँ

input[type="EMAIL"]   1 element
  input#email [type="email"]

button[data-state="ready"]   0 elements

button[data-state="ready" i]   1 element
  button [data-state="Ready"]  Join

a[href*=""]   0 elements

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node attributes.mjs

The output shows the differences that cause most mistakes:

  • ~= compares whole words, *= any part of the text. Rice is tagged grain wholesale, so [data-tags*="sale"] matches it as well, while [data-tags~="sale"] matches only the two items with the word sale. Use ~= for lists of words, such as tags.
  • |= was made for language codes. [lang|="hi"] matches Hindi in every form, hi, hi-IN and hi-Latn (Hindi typed in Latin letters), and leaves out Tamil, ta. [lang="hi"] matches only the section tagged plain hi. The next lesson’s :lang() goes further, because it also matches elements that inherit their language from an ancestor, which no attribute selector can see.
  • An empty value never matches with ^=, $= or *=: a[href*=""] matches nothing, by definition. [lang=""], with plain =, does match an attribute that is set to the empty string.

Whether a value’s case matters depends on the attribute. The HTML Standard lists 46 attributes whose values are compared without regard to ASCII case, among them type, lang, dir, rel and target. That is why input[type="EMAIL"] still finds type="email". Everything else keeps its case: class, id, href, every data-* attribute and every aria-* attribute. So button[data-state="ready"] misses data-state="Ready".

Two flags, written before the closing bracket, change that. i compares the value without regard to case, so a[href$=".pdf" i] finds both price-list.pdf and Stall-Map.PDF, and it works in every current browser. s does the opposite and forces an exact comparison even for an attribute on the HTML list. Firefox and Safari support s, but Chrome and Edge do not, and a browser that does not know a selector drops the whole rule (Chromium 153 rejects [type="email" s] as invalid). Use s only in rules where losing the whole rule in Chromium-based browsers is acceptable, or not at all.

Attribute selectors are also the natural way to style state that the markup already announces to assistive technology. nav a[aria-current="page"] marks the current page’s link, and [aria-expanded="true"] styles an open menu button. The style then follows the same attribute that screen readers announce, and the two cannot drift apart.

Combinators

A combinator joins two selectors and says how the two elements are related. CSS has four:

  • Descendant, a space: article p matches a p anywhere inside an article, at any depth.
  • Child, >: article > p matches a p whose parent is an article.
  • Next sibling, +: h2 + p matches a p that comes straight after an h2, with the same parent.
  • Subsequent sibling, ~: h2 ~ p matches every p that comes after an h2 with the same parent, straight after it or later.

Spaces around >, + and ~ are optional. The space of the descendant combinator is the combinator itself, which is why .listing .sale (a .sale inside a .listing) and .listing.sale (one element with both classes) are different selectors.

The four combinators on page.html JavaScript · lab/combinators.mjs
// The four combinators, run against page.html.
import { readPage, report } from "./selector-lab.mjs";

report(readPage("page.html"), [
  "article p",
  "article > p",
  "h2 + p",
  "h2 ~ p",
  "li.sale + li",
  "li.sale ~ li",
  "nav > a + a",
]);

Output

article p   3 elements
  p.lead  Fruit and vegetables from nearby farms.
  p  All prices include tax.
  p.note  Stalls open at 7 and close at noon.

article > p   2 elements
  p.lead  Fruit and vegetables from nearby farms.
  p.note  Stalls open at 7 and close at noon.

h2 + p   1 element
  p.lead  Fruit and vegetables from nearby farms.

h2 ~ p   2 elements
  p.lead  Fruit and vegetables from nearby farms.
  p.note  Stalls open at 7 and close at noon.

li.sale + li   2 elements
  li  Okra
  li  Rice

li.sale ~ li   3 elements
  li  Okra
  li.sale  Paneer
  li  Rice

nav > a + a   2 elements
  a  Stalls
  a  Directions

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node combinators.mjs

The children of article.listing in page.html, each labelled with the combinator selectors that match it.article.listingh2Fresh this weekp.lead, the next sibling of h2matched by article p, article > p,h2 + p and h2 ~ pdiv.boxp.note, a later sibling of h2matched by article p, article > pand h2 ~ pul.productsfive li, no pfootertwo linksp, a grandchild of articlematched by article p only

Which paragraphs each combinator reaches

Text description of the diagram

The diagram shows the article element of page.html as a box, with its six children stacked from top to bottom.

  1. h2, the heading "Fresh this week".
  2. p.lead, the paragraph right after the heading. It is matched by article p and article > p, by h2 + p, because it is the h2's next sibling, and by h2 ~ p, because it is a later sibling.
  3. div.box, which holds one more paragraph. That paragraph is a grandchild of the article, not a child and not a sibling of the h2, so only the descendant selector article p matches it.
  4. p.note, a child of the article that comes after the h2 but not right after it. It is matched by article p, article > p and h2 ~ p, but not by h2 + p.
  5. ul.products, with five list items and no paragraphs.
  6. footer, with two links.

Reading the output against the diagram:

  • article p finds three paragraphs and article > p two. The paragraph inside div.box is a grandchild of the article, so the child combinator skips it.
  • h2 + p finds only p.lead, the element straight after the heading. h2 ~ p also finds p.note, which comes later; the div.box between them does not stop it. Text between elements never counts, only elements do.
  • li.sale + li finds Okra and Rice, the items straight after each sale item. li.sale ~ li adds Paneer, which is a later sibling of Bananas, even though Paneer is on sale itself.
  • nav > a + a finds every link of the navigation except the first: each one has a link before it. It is a short way to put a separator between items.

Read a selector from the right. article > p means “a p whose parent is an article”, and article p means “a p with an article somewhere above it”. The element on the right is the one the rule styles; everything to its left only describes where it must be. Two things follow. Selectors can only look down the tree and forward along it: there is no parent combinator and no previous-sibling combinator, and the :has() pseudo-class, two lessons on, covers those cases. And a long chain such as main div ul li a breaks as soon as the markup gains or loses a wrapper element, so keep descendant chains short and anchor them on a class.

Selector lists, and the selector that breaks them

A comma turns several selectors into a selector list: h2, .note applies one block of declarations to both. Each selector in a list matches on its own, as if it had a rule of its own.

That stops being true when one selector in the list is invalid. Selectors Level 4 is explicit: a list that contains an invalid selector is invalid, and an invalid selector matches nothing. The browser drops the whole rule, so the valid selectors in the list lose their styles too, and nothing appears in the console.

A selector list with one bad selector JavaScript · lab/lists.mjs
// Selector lists: one rule for several selectors, and what a single invalid selector does to the list.
import { readPage, report } from "./selector-lab.mjs";

report(readPage("page.html"), [
  "h2, .note",
  "h2, .note, .2-col",
  "h2, .note,",
]);

Output

h2, .note   2 elements
  h2  Fresh this week
  p.note  Stalls open at 7 and close at noon.

h2, .note, .2-col
  invalid: "2-col" is not a valid class name: a name cannot start with a digit. The whole list matches nothing.

h2, .note,
  invalid: the list ends with a comma, so its last selector is empty. The whole list matches nothing.

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lists.mjs

Both broken lists are easy to write by accident:

  • A name that starts with a digit. Class names and IDs follow the rules for CSS names, which may not start with a digit (or with a hyphen and then a digit). In HTML, class="2-col" is fine, but .2-col is not a valid selector.
  • A trailing comma, left behind when a selector is deleted from the end of a list.

For a class or an ID that starts with a digit, escape the first character, or select the attribute instead, which takes any value in quotes:

Selecting a class or ID that starts with a digit CSS · escapes.css
/* A class or ID that starts with a digit is not a CSS name, so these two rules are dropped. */
.2-col {
  columns: 2;
}

#10-steps {
  counter-reset: step;
}

/* Escaping the first digit (\32 is "2", \31 is "1", and the space ends the escape) makes them valid. */
.\32 -col {
  columns: 2;
}

#\31 0-steps {
  counter-reset: step;
}

/* An attribute selector takes any value in quotes, with nothing to escape. */
[id="10-steps"] {
  counter-reset: step;
}

The same thing happens across browsers with vendor-prefixed selectors, which only one engine knows. In the rule below, Chromium 153 and WebKit 26.6 do not know ::-moz-placeholder, so they drop the rule, and the standard ::placeholder half goes with it; only Firefox 155 colours the placeholder. The fix is a rule of its own for each prefixed selector, or no prefix at all where every browser supports the standard one:

The vendor-prefix trap, and the fix CSS · vendor-list.css
/* One rule for two engines' selectors. Chromium and WebKit do not know ::-moz-placeholder, so they drop the
   whole rule, and the ::placeholder half is lost with it. Only Firefox applies this. */
input::placeholder,
input::-moz-placeholder {
  color: #4a4f57;
}

/* Fixed: ::placeholder works in every current browser. A prefixed selector that you still need goes in a rule
   of its own, so a browser that does not know it loses only that rule. */
input::placeholder {
  color: #4a4f57;
}

One family of prefixes behaves differently. Because so many sites put ::-webkit- pseudo-elements in their lists, an appendix of Selectors Level 4 requires browsers to accept any pseudo-element whose name starts with -webkit- and to let it match nothing when they do not know it. A list such as .a, .b::-webkit-scrollbar therefore keeps working in Firefox, while the same list with a ::-moz- name breaks in the other two engines.

Two pseudo-classes, :is() and :where(), take a forgiving list: inside them an invalid selector is skipped and the rest still match. A later lesson of this module covers both. They cannot hold pseudo-elements such as ::placeholder, so for those, separate rules remain the fix.

Choosing a selector for the job

Every kind of selector has a job it does best:

  • Type selectors set the base styles of the page: body text, headings, lists and links. They are the lightest rules, so everything else can override them.
  • Classes are the everyday hook for components and their variations, such as .card and .card.featured. A class can go on any element, as often as needed.
  • Attribute selectors style state and data that the markup already holds, such as [aria-current="page"] or [data-tags~="sale"], and weigh the same as a class.
  • IDs serve as link targets and label connections. As a styling hook, an ID matches one element and outweighs any number of classes, so a rule written for it is hard to reuse and hard to override.
  • Combinators describe context, such as .products > li + li. Keep them short, so the rule survives changes to the markup.

The stylesheet below styles page.html with one example of each, and with the combinators of this lesson:

store.css: the selectors of this lesson at work CSS · store.css
/* Styles for lab/page.html. Each rule uses one kind of selector from this lesson. */

/* Type selectors: every element of that name. */
body {
  font-family: system-ui, sans-serif;
  line-height: 1.5;
}

h2 {
  font-size: 1.5rem;
}

/* A class: every element that carries it, whatever its type. */
.note {
  color: #4a4f57;
  font-size: 0.9rem;
}

/* A compound selector, with no space: an li that also has the class sale. */
li.sale {
  font-weight: 700;
}

/* Attribute selectors: state and data that the markup already holds. */
nav a[aria-current="page"] {
  text-decoration-thickness: 0.2em;
}

a[href^="https:"] {
  text-decoration-style: dashed;
}

section[lang|="hi"] {
  font-family: "Noto Sans Devanagari", sans-serif;
}

section[lang="ta"] {
  font-family: "Noto Sans Tamil", sans-serif;
}

/* Combinators: a line between neighbouring products, a larger first paragraph after the heading. */
.products > li + li {
  border-top: 1px solid #c6ccd4;
}

h2 + p {
  font-size: 1.125rem;
}

Try it: selectors for page.html

Paste page.html and store.css into the playground and watch the styles apply:

HTML, CSS & JS Playground (Online Editor) Paste page.html into the HTML tab and store.css into the CSS tab, then edit a selector and watch the preview.

Then write one selector for each of these, without using an ID. Check each one in the playground’s JavaScript tab with console.log(document.querySelectorAll('…').length): the console under the preview should show the number in brackets.

  1. The two links in the footer (2).
  2. Every product except the first (4).
  3. The three sections in Hindi, in any of its forms, but not the Tamil one (3).
  4. The sign-up button, whatever the case of its data-state value (1).

One set of answers: footer a, .products > li + li, section[lang|="hi"] and button[data-state="ready" i]. Other answers can be just as right: .products li ~ li also finds the last four products.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Which list items does li.sale ~ li match in this list?

    Read the code, then choose one answer.

    <ul>
      <li>Mangoes</li>
      <li class="sale">Bananas</li>
      <li>Okra</li>
      <li class="sale">Paneer</li>
      <li>Rice</li>
    </ul>
    Show the answer to question 1

    Answer: Okra, Paneer and Rice

    ~ matches every later sibling of an element that matches the left side. Okra, Paneer and Rice all come after Bananas, so all three match, Paneer included, even though it is on sale itself. Okra and Rice alone is the answer for li.sale + li, which takes only the item straight after each sale item.

  2. Question 2 of 5 <section lang="hi-Latn"> holds Hindi written in Latin letters. Which selector matches it?

    Choose one answer.

    Show the answer to question 2

    Answer: [lang|="hi"]

    |= matches a value that is exactly hi or starts with hi followed by a hyphen, which is how language codes add a region or a script. = needs the whole value to be hi, ~= looks for the word hi in a list separated by spaces, and hi-Latn does not start with hi-IN.

  3. Question 3 of 5 What does a browser do with h2, .note, .2-col { color: navy; }?

    Choose one answer.

    Show the answer to question 3

    Answer: It drops the whole rule, so no h2 and no .note turns navy

    .2-col is not a valid selector, because a CSS name cannot start with a digit. A selector list that contains an invalid selector is invalid as a whole, so the browser drops the rule for all three, silently. Escape the digit (.\32 -col) or select the attribute ([class~="2-col"]).

  4. Question 5 of 5 In page.html, a p sits inside div.box, which sits inside the article. Which selector matches that p?

    Choose one answer.

    Show the answer to question 5

    Answer: article p

    The descendant combinator, a space, reaches any depth. article > p needs the article to be the paragraph's parent, but its parent is the div. h2 ~ p looks for later siblings of the heading, and the paragraph is not one. h2 + div matches the div itself, not the paragraph inside it.

Interview questions

What is the difference between .a .b, .a > .b and .a.b?

.a .b, with a space, is a descendant selector: it matches an element with the class b anywhere inside an element with the class a, at any depth. .a > .b is a child selector: the .b element must be a direct child of the .a element, one level down. .a.b, with no space, is a compound selector: one element that carries both classes. The space is not formatting but the descendant combinator, so adding or removing it changes what matches. On a page where an article holds two paragraphs directly and a third inside a div, article p matches three paragraphs and article > p only two. All three selectors carry the same weight, two classes, so the difference lies only in which elements they match.

Key takeaways

  • A type selector picks elements by name, a class selector by class, an ID selector the one element with that ID, and an attribute selector by an attribute or its value. Written together with no space, they describe one element.
  • Classes are the everyday styling hook. IDs outweigh any number of classes and match one element, so keep them for link targets and labels.
  • = is exact, ~= one word of a list, |= a language code and its subtags, ^= $= *= the start, end or any part. An empty value never matches with the last three, and i ignores case in every current browser.
  • HTML compares the values of type, lang and 44 other attributes without regard to case; class, id, data-* and aria-* values keep their case.
  • The combinators are a space (descendant), > (child), + (next sibling) and ~ (any later sibling). Selectors only look down and forward; :has() looks the other way.
  • One invalid selector, a vendor prefix another engine does not know, a name that starts with a digit or a trailing comma, drops the whole rule for every selector in its list. ::-webkit- pseudo-elements are the exception, and :is() and :where() forgive the rest.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.