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.
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.
<!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.
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 exactlyemail.[data-tags~="sale"]: its value is a list of words separated by spaces, and one of them issale.[lang|="hi"]: its value is exactlyhi, or starts withhifollowed by a hyphen.[href^="https:"]: its value starts withhttps:.[href$=".pdf"]: its value ends with.pdf.[href*="map"]: its value containsmapanywhere.
// 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 taggedgrain wholesale, so[data-tags*="sale"]matches it as well, while[data-tags~="sale"]matches only the two items with the wordsale. Use~=for lists of words, such as tags.|=was made for language codes.[lang|="hi"]matches Hindi in every form,hi,hi-INandhi-Latn(Hindi typed in Latin letters), and leaves out Tamil,ta.[lang="hi"]matches only the section tagged plainhi. 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 pmatches apanywhere inside anarticle, at any depth. - Child,
>:article > pmatches apwhose parent is anarticle. - Next sibling,
+:h2 + pmatches apthat comes straight after anh2, with the same parent. - Subsequent sibling,
~:h2 ~ pmatches everypthat comes after anh2with 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, 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
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.
- h2, the heading "Fresh this week".
- 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.
- 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.
- 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.
- ul.products, with five list items and no paragraphs.
- footer, with two links.
Reading the output against the diagram:
article pfinds three paragraphs andarticle > ptwo. The paragraph insidediv.boxis a grandchild of the article, so the child combinator skips it.h2 + pfinds onlyp.lead, the element straight after the heading.h2 ~ palso findsp.note, which comes later; thediv.boxbetween them does not stop it. Text between elements never counts, only elements do.li.sale + lifinds Okra and Rice, the items straight after each sale item.li.sale ~ liadds Paneer, which is a later sibling of Bananas, even though Paneer is on sale itself.nav > a + afinds 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.
// 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-colis 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:
/* 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:
/* 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
.cardand.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:
/* 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:
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.
- The two links in the footer (2).
- Every product except the first (4).
- The three sections in Hindi, in any of its forms, but not the Tamil one (3).
- The sign-up button, whatever the case of its
data-statevalue (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.
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, andiignores case in every current browser.- HTML compares the values of
type,langand 44 other attributes without regard to case;class,id,data-*andaria-*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
- Selectors Level 4 (W3C CSS Working Group)
- Selectors Level 4, Appendix B: -webkit- parsing quirks for web compatibility (W3C CSS Working Group)
- HTML Standard: case-sensitivity of selectors (WHATWG)
- CSS Syntax Module Level 3 (identifiers and escapes) (W3C CSS Working Group)
- Attribute selectors (MDN Web Docs) (Mozilla)
- Combinators (MDN Web Docs) (Mozilla)
- Selector list (MDN Web Docs) (Mozilla)
- MDN browser-compat-data: attribute selector case modifiers (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress