A selector looks like a description of elements, and most of the time that is how people write one: nav ul li a reads as "the links in list items in lists in the navigation". The specification does not read it that way. Selectors Level 4 (W3C Working Draft, 22 January 2026) defines a selector by what it matches, and defines matching as an algorithm that starts at the right-hand end and works back towards the left. Once that is clear, most of the surprises in selector behaviour stop being surprises: why table > tr matches nothing in a table written without a tbody, why [type=CHECKBOX] matches a lower-case type="checkbox" while .LEAD does not match class="lead", why :is(:unknown, p) works in a browser that has never heard of :unknown, and why [unclosed is a valid selector. The CSS selector tester runs this algorithm over any HTML you paste, in the page, and marks each selector's matches in the tree the parser built. This article is the reading of the specification behind it. The specification was read on 2026-10-05 and is quoted as it stands.
Right to left, one compound at a time
A complex selector is a chain of compound selectors joined by combinators: in nav > ul li:first-child a, the compounds are nav, ul, li:first-child and a, and the combinators are child, descendant and descendant. The specification's matching algorithm (section 17.3) takes an element and the chain and says: "To match a complex selector against an element, process it compound selector at a time, in right-to-left order." The element is first checked against the rightmost compound, a. If that fails, the whole selector fails for this element at once. If it passes, the algorithm looks for an element related to it by the rightmost combinator (here, any ancestor, for the descendant combinator) that matches the rest of the chain, and recurses.
This order is why selectors are cheap for browsers to evaluate, and why the rightmost compound is the one that decides most. nav ul li a does not find the navigation and walk down; it finds every a and walks up. When a selector is run against a whole document, as querySelectorAll does, the candidates are "the root elements and all of their descendant elements, sorted in shadow-including tree order" (17.5), and the result keeps that document order whatever order the selector's parts were written in. The tester shows the matches in exactly this order, with each element's path and the line it came from.
The document language decides what "the same name" means
Section 3.7 draws the line: "All Selectors syntax is case-insensitive (i.e. [a-z] and [A-Z] are equivalent), except for the parts that are not under the control of Selectors: specifically, the case-sensitivity of document language element names, attribute names, and attribute values depends on the document language." Pseudo-class names, keywords such as odd and even, the i and s flags: all case-insensitive. Element names, attribute names and attribute values: whatever the document language says.
For HTML documents the HTML Standard says it in its "Case-sensitivity of selectors" section: "When comparing a CSS element type selector to the names of HTML elements in HTML documents, the CSS element type selector must first be converted to ASCII lowercase", and the same for "the name part of the CSS attribute selector". So P.LEAD finds every p, because P is lower-cased before the comparison. The class part is another matter: class and id values are case-sensitive, so .LEAD does not match class="lead". (The Quirks Mode Standard once defined a quirk that made class and id selectors case-insensitive in quirks-mode documents; the current standard, read on 2026-10-05, no longer has it.)
Attribute values are case-sensitive by default, with one list of exceptions. The HTML Standard names 46 attributes whose values an attribute selector "must treat ... as ASCII case-insensitive", among them type, lang, dir, rel, media, method, target, checked, disabled and readonly. That is why input[type=CHECKBOX] matches type="checkbox", while [title=HELLO] does not match title="Hello". Selectors 4 adds two flags to override the default (6.3): [title="hello" i] compares case-insensitively whatever the language says, and [type="checkbox" s] compares case-sensitively even for one of the 46; the HTML Standard gives this very example, "The selector [type=a s] will match any HTML element with a type attribute whose value is a, but not whose value is A". The s flag is in the specification and not yet in Chromium (checked on 2026-10-05); the tester accepts it and says so.
Escapes follow CSS: "the selector #foo>a contains a combinator, but #foo>a instead selects an element with the id foo>a" (3.7). The hexadecimal form works too, with one detail from CSS Syntax Level 3 (4.3.7): a hex escape consumes one whitespace character after it, so #foo\3e a is still the single identifier foo>a, and only #foo\3e a, with two spaces, is an id followed by a type selector.
Attributes: seven operators and the empty string
Section 6 defines seven attribute selectors. [att] is presence. [att=val] is exact equality. [att~=val] treats the value as "a whitespace-separated list of words, one of which is exactly val", which is what a class selector is: section 6.6 says that in HTML the class selector "is equivalent to the = notation applied to the local class attribute (i.e. [class=identifier])". [att|=val] matches a value "either being exactly val or beginning with val immediately followed by - (U+002D)", written for language codes ([lang|=pt] matches pt and pt-BR). [att^=val], [att$=val] and [att*=val] are begins-with, ends-with and contains.
Four of them share a rule that trips people up when a value is built dynamically: the empty string matches nothing. For ~=, "if val is the empty string, it will never represent anything", and the same sentence is repeated for the three substring operators (6.2). [class~=""] and [href^=""] are valid selectors that match no element at all, which is not what a programmer expects from "starts with the empty string".
Counting siblings
The tree-structural pseudo-classes (section 13) count children. :nth-child(An+B) "represents elements that are among An+Bth elements from the list composed of their inclusive siblings", 1-indexed; 2n+1 and odd are the first, third, fifth; -n+3 is the first three; n+2 is everything from the second. Level 4 adds the of S form: :nth-child(2 of .k) is the second sibling among those matching .k, which is not .k:nth-child(2), the second sibling if it also happens to be .k. The specification spells out the default: "If S is omitted, it defaults to |". The typed forms (:nth-of-type(), :first-of-type, :only-of-type) count only siblings with the same element type and take no of S.
The combinators (section 14) are defined in terms of elements only. For the next-sibling combinator +: "The elements represented by the two compound selectors share the same parent in the document tree and the element represented by the first compound selector immediately precedes the element represented by the second one. Non-element nodes (e.g. text between elements) are ignored when considering the adjacency of elements." Text, comments and whitespace between two elements do not break h1 + p, and they do not count as children for :first-child either. What does change the count is the tree the HTML parser builds rather than the one you typed: table > tr matches nothing in a document written without tbody, because the parser inserted one, and the HTML structure explainer shows that insertion. The tester runs the same parser, so its counts are the browser's counts.
The logical pseudo-classes, and the list that forgives
Section 4 defines four pseudo-classes that take selectors as arguments. :is() matches an element that any of its arguments matches; :where() is the same "with the same syntax and functionality as :is()" except that "its specificity is always zero" (4.4); :not() "represents an element that is not represented by its argument" (4.3); :has() "represents an element if any of the relative selectors would match at least one element when anchored against this element" (4.5), which is the long-requested parent selector: li:has(> a[href]) is a list item with a link child, section:not(:has(h1, h2, h3)) a section without a heading. Two limits are stated outright: ":has() is not valid within :has()", and pseudo-elements are not valid inside any of the four.
The difference between :is() and :not() that matters most is not what they match but what they do with an argument that fails to parse. The default rule for a selector list is harsh: "a selector list containing an invalid selector is invalid" (3.9), so one unknown pseudo-class in a:hover, a:unknown kills the whole rule. Section 16.1 explains the cost: "if any selector in the list fails to parse (because it uses new or UA-specific selector features, for instance), the entire selector list becomes invalid. This can make it hard to write CSS that uses new selectors and still works correctly in older user agents." The remedy is the forgiving selector list, which "instead parses each selector in the list individually, simply ignoring ones that fail to parse, so the remaining selectors can still be used", and it is "used in :is() and :where() only". So :is(:unknown, p) is valid and matches every p, :not(:unknown, p) is invalid and matches nothing, and :is(:unknown) is a valid selector whose list is empty: "If the argument, after parsing, is an empty list, the pseudo-class is valid but matches nothing" (4.2). Wrapping a rule's selector in :is() is a documented way to make it forgiving.
Specificity follows the same structure (section 15): ids count in the first place, classes, attributes and pseudo-classes in the second, types and pseudo-elements in the third, and the universal selector counts nothing. The four logical pseudo-classes are the exception that makes the rule interesting: :is(), :not() and :has() take "the specificity of the most specific complex selector in its selector list argument", :where() is "replaced by zero", and :nth-child(An+B of S) is one pseudo-class plus the most specific member of S. The specification's own examples are the best test of an implementation: :is(em, #foo) is (1,0,0), .qux:where(em, #foo#bar#baz) is (0,1,0), :nth-child(even of li, .item) is (0,2,0), :not(em, strong#foo) is (1,0,1). Members a forgiving list dropped "must be treated as having zero specificity" (16.1). The tester counts specificity from the parsed selector, so these come out as the specification says; the CSS specificity calculator counts the text as written and ranks several selectors against each other.
What a static document cannot tell you
Some pseudo-classes describe the user, not the document. For :hover, :active, :focus, :focus-visible and :focus-within, section 9 is explicit: "In non-interactive user agents, these pseudo-classes are valid, but never match any element." A tool that reads HTML without rendering it is a non-interactive user agent, and so is a server rendering a page or a test that parses a fixture; such a tool answers "valid, zero matches", and it should say why rather than leave the zero to be misread. :visited comes "with obvious privacy implications", and the first of the implementations section 8.2 allows is to "Have :visited never match, so all links match :link instead"; the tester does exactly that. :target, the media states and the shadow-tree pseudo-classes have no meaning in a static tree either.
The form pseudo-classes split in two. :checked, :disabled, :enabled, :required, :optional, :read-only, :read-write and :open are defined by the HTML Standard in terms of attributes and ancestry, so they can be read off a static tree: a disabled fieldset "causes all the form control descendants of the fieldset element, excluding those that are descendants of the fieldset element's first legend element child, if any, to be disabled", which the tester honours. :valid, :invalid, :in-range, :out-of-range, :indeterminate, :default and :placeholder-shown depend on constraint validation and on state a browser computes; the tester does not evaluate them and says so, which is why input:valid is the one selector where it and Chromium part ways on the shipped vectors by design.
:empty is a case where the specification moved and the browser engine checked here has not yet followed. Level 4 says an element is empty when it "has no children except, optionally, document white space characters", and the note explains the change from Level 3, where whitespace counted as content: "given white space is largely collapsible in HTML and is therefore used for source code formatting ... elements which authors perceive of as empty can be selected". Chromium, checked on 2026-10-05, still applies the Level 3 rule, so <p> </p> does not match p:empty in the browser. The tester follows the browser and attaches a note, because a tool that disagrees with the browsers on a visible behaviour teaches the wrong lesson even when it agrees with the draft.
A selector that ends in a pseudo-element (p::first-line, li::before) names a box, not an element; section 3.6 says a pseudo-element "represents" the pseudo-element, and querySelectorAll returns nothing for such a selector by design. The tester instead lists the elements that would originate the pseudo-element, and labels the selector, which is the question a style author is actually asking.
Invalid, and what browsers do with an unfinished selector
Section 3.9 is short and total: "An invalid selector represents, and therefore matches, nothing." Invalid means a parsing error ("an unrecognized token or a token which is not allowed at the current parsing point"), an undeclared namespace prefix, an empty selector in a list, and, by the note that keeps CSS extensible, anything a user agent does not support: UAs "must treat as invalid any pseudo-classes, pseudo-elements, combinators, or other syntactic constructs for which they have no usable level of support". That last clause is why a browser without :has() support silently dropped every rule that used it, and why the forgiving list exists.
One case people to be invalid is not. [unclosed, :is( and a[href^="/d all parse. CSS Syntax Level 3 (Candidate Recommendation Draft, 1 October 2026) says what happens when the input ends inside a block or a function: for a simple block, on <eof-token>, "Discard a token from input. Return block." (5.5.9), and the same for a function (5.5.10); for a string, the end of input "is a parse error" and the string token is returned anyway (4.3.5). The specification's own explanation, in its non-normative overview, is that "by the time the parser is finished, the presence of the ending token is no longer possible to determine; all you have is the fact that there's a block and a function". So [unclosed is [unclosed], :is( is :is() (valid, matches nothing), and a[href^="/d is a[href^="/d"]. Chromium's querySelectorAll agrees on every one of them. The tester reads them the same way and attaches a note saying where the input ran out, because a selector that works by accident in one place will fail in another the moment something follows it.
Sources
- Selectors Level 4, W3C Working Draft 22 January 2026 (read 2026-10-05): 3.7, 3.9, 4, 6, 9, 13, 14, 15, 16.1, 17.3 and 17.5
- HTML Living Standard, Interactions with CSS: Case-sensitivity of selectors and the pseudo-classes (read 2026-10-05)
- HTML Living Standard, the fieldset element (read 2026-10-05)
- CSS Syntax Module Level 3, W3C Candidate Recommendation Draft 1 October 2026 (read 2026-10-05): 2.2, 4.3.5, 4.3.7, 5.5.9, 5.5.10
- Quirks Mode Standard, WHATWG Living Standard, last updated 30 September 2026 (read 2026-10-05)