CSS Basics showed the shape of a rule: a selector, then a declaration block. This page is about the first half. It covers what a selector is made of, the kinds of selector the language offers, how they walk the tree, and the score the browser gives each one when two rules disagree.
1. The shape of a selector
A selector is a pattern. The browser tests each element in the document against it, and the elements that pass are the ones the rule styles. Put another way, a selector is a predicate: a function that takes an element and returns true or false.
If that sounds like a query language, it is one. The same idea appears in SQL's WHERE clause and in XPath, and the browser exposes the CSS version directly to JavaScript: document.querySelectorAll('nav a') returns exactly the elements that the selector nav a would style. Learning selectors well pays twice, once in style sheets and once in scripts.
Simple, compound and complex
Selectors are built in three steps.
- A simple selector tests one thing: a tag name (
a), a class (.note), an attribute ([href]) or a pseudo-class (:hover). - A compound selector joins simple selectors with no space between them, and every part must match the same element.
a[href]:hovermeans a link element, that has anhref, that is under the pointer. - A complex selector joins compounds with combinators, which describe a relationship in the tree: inside, directly inside, or next to.
The rightmost compound is the subject: the element the rule actually styles. Everything to its left is context. In nav > a[href^="/"]:hover the rule styles a link, and only when that link sits directly inside a nav.
Read from the right
Browsers evaluate a selector from right to left. They find the elements that could match the subject, then walk up or across the tree to check the rest. That is the efficient direction, since most elements fail the subject test at once. It has one practical lesson: what makes a selector costly is a subject that matches almost everything, such as a bare * at the end, not the length of the chain. On modern engines selector speed is rarely worth worrying about; selector clarity always is.
Finally, a comma makes a selector list. h1, h2, h3 is shorthand for three rules with the same declarations. Each member is matched on its own and, as section 7 shows, scored on its own.
Case, and the doctype switch
In an HTML document, type selectors ignore case: H1 and h1 select the same elements. Class names and ids are case-sensitive, so .Note does not match class="note". There is one historical exception. A page with no <!doctype html> renders in quirks mode, which imitates the bugs of browsers from the late 1990s, and in quirks mode class and id matching becomes case-insensitive, along with several layout differences. The doctype is the switch that turns the standards behavior on. Always write it, and if a page behaves strangely, check it first.
2. Type, class, id and attribute selectors
Five kinds of simple selector match elements by what is written in the markup.
| Selector | Example | Matches |
|---|---|---|
| Universal | * | Every element |
| Type | article, my-card | Elements with that tag name, built in or custom |
| Class | .warning | Elements whose class attribute contains that word |
| Id | #intro | The element whose id is exactly that value |
| Attribute | [href], [type="email"] | Elements with that attribute, or that attribute value |
Type selectors come first
The type selector is the workhorse of structure-first CSS. If the HTML uses header, nav, article, figure and blockquote for what they mean, a style sheet can say how each kind of thing looks without inventing any names. Type selectors also work on custom elements: browser-window matches every <browser-window> on this site, which is one reason components built as custom elements need no class-naming convention.
When a class earns its place
A class attribute groups elements that the author decides belong together. An element can carry several, separated by spaces, and a class selector matches if any of the words match: .warning matches class="note warning".
The habit to avoid is putting a class on everything, sometimes called class-itis: <p class="para">, <ul class="nav-list">, <h2 class="title">. Each of those names repeats something the element already says, and each one has to be kept in step with the markup by hand. Classes earn their place for variations the markup cannot express. HTML has one aside element, but a design might have a plain note and a warning. That difference is not in the element, so a class (or a data- attribute) carries it:
The base rule styles every aside by its structure. The class only changes one token, so the variation stays small and the two kinds of aside cannot drift apart.
Before reaching for a class, check whether the markup already has the information. A link to the current page should carry aria-current="page" for assistive technology anyway, so nav a[aria-current="page"] styles it without a class. An expanded disclosure button carries aria-expanded="true"; a required field carries required. Styling the attribute that carries the meaning means the look and the meaning cannot disagree.
Why ids are poor styling hooks
Every element can have an id, and #intro selects it. Ids are valuable: they are fragment targets for links (/page/#intro), they connect a label to its control with for, they wire up ARIA relationships such as aria-describedby, and scripts find elements with them. They are poor hooks for style, for three reasons.
- They are too strong. One id outweighs any number of classes in the specificity score (section 7), so every later rule that wants to change the element has to use an id too.
- They do not reuse. An id is unique in its document, so a rule for
#homeNavbarstyles one element on one page, and the next page gets#subNavbarand a copy of the rule. - They couple style to identity. An id exists so that something can point at the element. Renaming it for a link or a script then breaks the style, and vice versa.
A compound such as div#main is usually a further warning sign: an id is already unique, so the type adds only more weight. Leave ids for links, labels, ARIA and scripts, and style the structure.
Attribute selectors
Attribute selectors match on the presence of an attribute or on its value, and they have a small set of operators worth memorizing.
| Syntax | Matches when the attribute | Example |
|---|---|---|
[attr] | is present, with any value | img[alt] |
[attr="v"] | is exactly v | input[type="email"] |
[attr~="v"] | contains v as one space-separated word | [rel~="external"] |
[attr|="v"] | is v, or starts with v- | [lang|="en"] matches en, en-US |
[attr^="v"] | starts with v | a[href^="https:"] |
[attr$="v"] | ends with v | a[href$=".pdf"] |
[attr*="v"] | contains v anywhere | a[href*="ucsd.edu"] |
[attr="v" i] | matches, ignoring case | a[href$=".pdf" i] |
A class selector is shorthand for one of these: .warning means [class~="warning"]. Attribute selectors are especially useful with generated content, to mark link types or file formats without touching the markup:
The last rule is a development aid: it outlines any image missing its alt attribute, so an accessibility mistake shows up on screen. (:not() is covered in section 6.)
3. The document tree and combinators
The key to understanding selectors is that the browser never styles the text of an HTML file. It parses the file into a tree of elements, the DOM, and selectors are patterns over that tree. Every element has one parent, and may have children and siblings. Combinators are the words of that family relationship.
| Combinator | Name | Matches B when it is |
|---|---|---|
A B (a space) | Descendant | anywhere inside A, at any depth |
A > B | Child | a direct child of A |
A + B | Next sibling | immediately after A, with the same parent |
A ~ B | Subsequent sibling | anywhere after A, with the same parent |
The difference between the first two catches people out. Given an article whose direct children are a heading, two paragraphs and a section, and a third paragraph inside that section:
Neither is better. article p says "paragraphs in articles", which is usually what a typographic rule means. article > p says "the article's own paragraphs", which is right when a nested section or figure should keep its own look. Choose the combinator that states the relationship you mean.
The sibling combinators style an element by what comes before it. h2 + p selects the paragraph that immediately follows each heading, the classic hook for a larger lead paragraph. h2 ~ p selects every paragraph after the heading within the same parent. Combinators only look down and forward; the way to select by what comes after or inside is :has(), in section 6.
Nesting writes combinators for you
Native nesting, introduced in Basics, produces exactly these selectors. A nested rule with no combinator means a descendant; a leading > or + means a child or sibling:
That block means article p, article > h2 + p and article figure ~ p. Nesting changes how selectors are written, not how they match.
Why a malformed tree matters
Selectors match the tree the parser built, which is not always the tree the author meant. HTML parsing never fails: the parser repairs broken markup by a fixed set of rules, and the repaired tree is the one CSS sees. A common case is a block element inside a paragraph:
A paragraph cannot contain a list, so the parser closes the p as soon as the ul begins. The list becomes the paragraph's next sibling, and the stray </p> creates a second, empty paragraph after it. A rule for p ul matches nothing, p + ul suddenly matches, and an empty paragraph adds margin to the layout. The CSS is not wrong; the tree is. When a selector refuses to match, inspect the element in DevTools and look at the tree the browser actually built. HTML as a language walks through what the parser does with broken markup, and a validator catches it before it reaches CSS.
4. Pseudo-classes
Type, class and attribute selectors match what is written in the markup. A pseudo-class matches something that is not written there: what the user is doing, the state of a form control, the URL, or the element's position among its siblings. Pseudo-classes start with one colon and act like a class the browser adds and removes on its own.
User action
| Pseudo-class | Matches when |
|---|---|
:link, :visited | a link has not, or has, been visited |
:any-link | an element is a link, visited or not |
:hover | the pointer is over the element |
:active | the element is being activated, such as a pressed button |
:focus | the element has focus, by any means |
:focus-visible | the element has focus and the browser judges that a focus indicator should show, chiefly after keyboard use |
:focus-within | the element or anything inside it has focus |
:target | the element's id matches the URL's fragment |
The link states are the oldest pseudo-classes, and their order matters. Several of them can match one link at once, and they all have the same specificity, so the rule written later wins. The traditional order, link, visited, hover, active, puts the momentary states last so they can override the lasting ones:
:visited carries a lesson. Early on, a page could style visited links and then ask, in script, what color a link had been given, and so learn which other sites a reader had been to. Browsers responded by restricting :visited to a few color properties and by reporting the unvisited style to scripts. That fix closed the leak by limiting the feature, which is why visited styling is so plain today.
Three cautions about the other states:
- Hover is a bonus, not a channel. Touch screens have no hover, and keyboard users never trigger it. Anything shown on hover must also be reachable another way, and hover effects can be limited to devices that hover with
@media (hover: hover). - Do not move things on hover. A change of size, padding or border width on hover makes the layout shift under the pointer. Change color, underline or shadow instead.
- Never remove focus without replacing it.
:focus-visibleexists so that a design can show a strong focus ring to keyboard users without showing it on every mouse click. Use it to style focus, not to hide it.
Form state
Form controls expose their state through pseudo-classes, so CSS can respond to it without any script.
| Pseudo-class | Matches |
|---|---|
:checked, :indeterminate | checkboxes, radio buttons and options in that state |
:default | the default option, checkbox or submit button |
:enabled, :disabled | controls that can or cannot be used |
:required, :optional | controls with and without the required attribute |
:valid, :invalid | controls whose value does or does not meet its constraints, from the start |
:user-valid, :user-invalid | the same, but only after the user has interacted with the control |
:in-range, :out-of-range | number and date inputs inside or outside their min and max |
:read-only, :read-write | elements the user cannot, or can, edit |
:placeholder-shown | inputs currently showing their placeholder |
The pair to remember is :invalid against :user-invalid. A required field that is empty is invalid the moment the page loads, so styling :invalid paints every required field red before the reader has typed a letter. :user-invalid, Baseline since late 2023, waits until the reader has changed the field or tried to submit, which is almost always the moment an error message belongs:
Tree position
Structural pseudo-classes match by an element's place among its siblings. They let CSS style the first item, the last item or every other row without adding a class to any of them, and they keep working when items are added or removed.
| Pseudo-class | Matches |
|---|---|
:root | the root element, html in an HTML document |
:empty | an element with no children at all, not even text |
:first-child, :last-child, :only-child | the first, last or only child of its parent |
:first-of-type, :last-of-type, :only-of-type | the same, counting only siblings of the same element type |
:nth-child(an+b), :nth-last-child(an+b) | children whose position fits the formula, counted from the start or the end |
:nth-of-type(an+b), :nth-last-of-type(an+b) | the same, counting only siblings of the same type |
:lang(fr), :dir(rtl) | elements in a language, or a writing direction |
The an+b formula is a small language of its own. n counts 0, 1, 2 and so on, and an element matches when the result equals its position, counting from 1.
:nth-child(2)is the second child.:nth-child(odd), or2n+1, is the first, third, fifth.:nth-child(3n)is every third child.:nth-child(-n+3)is the first three.:nth-child(odd of :not([hidden]))counts only the siblings that match the selector afterof, so hidden rows do not break the stripes.
The difference between -child and -of-type is what gets counted. In an article that begins with an h2, p:first-child matches nothing, because the first child is the heading, while p:first-of-type matches the first paragraph.
5. Pseudo-elements
A pseudo-class picks out an element. A pseudo-element picks out a part of one, or creates a box that is not in the DOM at all. Pseudo-elements take two colons, which keeps the two ideas visually apart. The four oldest, ::before, ::after, ::first-letter and ::first-line, still accept a single colon for backward compatibility, which is why old style sheets mix the two; write two in new code.
| Pseudo-element | Targets |
|---|---|
::before, ::after | a generated box at the start or end of the element's content |
::first-letter, ::first-line | the first letter, or the first rendered line, of a block |
::marker | the bullet or number of a list item |
::placeholder | the placeholder text in an input |
::selection | the text the user has selected |
::file-selector-button | the button inside <input type="file"> |
::backdrop | the layer behind a modal dialog, popover or full-screen element |
::before and ::after generate content, and they render nothing without a content declaration. The generated box is placed inside the element, as if it were its first or last child, so it inherits the element's styles. The content can be a string, an attr() value, a counter, or quotation marks that follow the language:
Keep meaning out of generated content. Screen readers announce it inconsistently, it cannot be selected or copied in most browsers, and it disappears when the style sheet does. Decorative quotation marks, separators and icons are good uses; a warning or a price is not. If the words matter, put them in the HTML.
::first-line depends on layout: which words are on the first line changes with the width of the window and the size of the text, and the browser recomputes it each time. It is a reminder that the author does not decide where lines break; the reader's screen does.
6. :is(), :where(), :not() and :has()
Four pseudo-classes take other selectors as arguments. They are the logic of the selector language: or, not and has.
| Pseudo-class | Matches when | Its specificity | List is |
|---|---|---|---|
:is(A, B) | any argument matches | its most specific argument | forgiving |
:where(A, B) | any argument matches | always zero | forgiving |
:not(A, B) | no argument matches | its most specific argument | not forgiving |
:has(A) | something relative to the element matches A | its most specific argument | not forgiving |
:is(), for shorter lists
:is() factors out the part of a selector list that varies. These two rules match the same elements:
Forgiving selector lists
As Basics showed, one selector the browser cannot parse throws away a whole ordinary selector list, and the rule with it. :is() and :where() take a forgiving selector list instead: an argument the browser does not understand is dropped and the rest still match. That makes them a safe place for a newer pseudo-class alongside an older one:
A browser that has never heard of :future-state (a made-up name) still applies the rule to :user-invalid. Written as a plain list, input:user-invalid, input:future-state, the whole rule would be dropped. :not() and :has() do not forgive: an invalid argument invalidates them.
:where(), for zero-specificity hooks
:where() matches exactly what :is() matches, but whatever is inside it counts for nothing in the specificity score. That makes it the tool for base styles and hooks that should be easy to override:
The first rule has a specificity of zero, so a plain h2 { … } anywhere later beats it without any extra weight. The second keeps the hook weak: nav :where(a[aria-current="page"]) scores like nav alone. A design system that writes its defaults this way leaves its users free to adjust them with ordinary selectors.
:not()
:not() matches elements that match none of its arguments. li:not(:last-child) styles every item except the last, which is the cleanest way to put space or a separator between items. It takes a list, so button:not([disabled], [aria-disabled="true"]) excludes both.
:has(), the relational selector
For most of the history of CSS, selectors could only look down and forward: from an ancestor to its descendants, from an element to the siblings after it. There was no way to style an element because of what it contained, and "a parent selector" was the most requested feature in the language for well over a decade. :has() provides it. It became Baseline in December 2023 and is now widely available.
:has() matches an element if its argument, read relative to that element, matches something. With no combinator, the argument looks at descendants; with >, at children; with + or ~, at later siblings:
Read each one aloud: a figure that has a caption; the submit button of a form that has an invalid field; a heading followed by a paragraph; the body while a dialog is open. Each used to need a script that watched the page and toggled a class. Now the state already in the markup is enough, and the style updates the moment it changes.
7. Specificity, and keeping it low
When two rules set the same property on the same element, the cascade decides which wins. Origin and importance come first and cascade layers next, as the cascade page explains. Within the same layer, the tie-breaker is specificity: a score computed from the selector.
Reading the score
Specificity is three numbers, often written (a, b, c). Count the parts of the selector into three columns:
- a, id: each id selector.
- b, class: each class, attribute selector and pseudo-class.
- c, type: each type selector and pseudo-element.
The universal selector and the combinators count nothing. :where() counts nothing; :is(), :not() and :has() count as their most specific argument.
Scores compare column by column, from the left, like version numbers. The first column that differs decides, and if all three are equal, the rule that comes later in the source wins. So (0, 1, 0) beats (0, 0, 9): one class outweighs any number of type selectors. And (1, 0, 0) beats (0, 15, 0): one id outweighs any number of classes. There is no carrying from one column to the next.
Two more details. Each member of a selector list is scored on its own: in h1, .title { … }, an h1 is styled at (0, 0, 1) and a .title at (0, 1, 0). :is() is different: :is(h1, .title) scores (0, 1, 0) whichever argument matched. And a declaration in a style attribute has no selector at all; it beats every normal selector-based author rule, as if it scored in a column of its own to the left of ids.
The specificity war
Specificity is only a problem when it climbs. The pattern is familiar from older style sheets: a rule does not apply, so the author makes the selector longer; that rule then beats a different one, which gets an id; and when nothing else works, !important.
Every step makes the next change harder, and the style sheet ends up ordered by force rather than by intent. The cure is not a bigger weapon. It is to keep every selector's score low and roughly equal, so that source order and layers, which the author controls directly, decide the outcome.
Keeping it low
- Style the structure. Type selectors score (0, 0, 1). A style sheet built mostly from them leaves room above for everything else.
- Stay shallow. Nest to show structure, but one or two levels is enough.
nav ais clearer and weaker thanbody header nav ul li a. - One class, not a chain. When a class is warranted, one usually suffices.
- No ids in style sheets. They are worth more than every class combined.
- Wrap hooks in
:where(). Defaults and hooks stay at zero, easy to override. - Order with layers, not with weight. A rule in a later
@layerbeats a rule in an earlier one whatever their specificity. That is how a style sheet says "components override base styles" without a single extra selector.
The rule in base scores (0, 0, 3) and the one in components only (0, 1, 1), yet the components rule wins because its layer comes later. Specificity still settles ties within a layer, but it no longer has to carry the architecture of the style sheet. The cascade page shows the whole sort and how layers, @scope and nesting fit together.
8. Summary
| Concept | Key points |
|---|---|
| Selectors as queries | A selector is a predicate over the document tree, the same language querySelectorAll() uses. Simple selectors form compounds; combinators link compounds; the rightmost compound is the subject. |
| Simple selectors | Type selectors first, for structure. A class earns its place for a variation the markup cannot express. Ids are for links, labels, ARIA and scripts, not style. Attribute selectors match presence, exact values, words, prefixes and suffixes. |
| The document tree | Space for descendants, > for children, + and ~ for later siblings. Nesting writes the same selectors. CSS matches the repaired tree, so malformed markup breaks selectors. |
| Pseudo-classes | Match user action, form state, the URL fragment and tree position. Prefer :focus-visible and :user-invalid. Treat hover as a bonus. an+b and of S pick positions without classes. |
| Pseudo-elements | Two colons. ::before and ::after need content and sit inside the element. Keep meaning out of generated content. |
:is(), :where(), :not(), :has() | :is() shortens lists; it and :where() forgive unknown arguments. :where() scores zero. :has() selects by contents or following siblings and is widely available. |
| Specificity | (id, class, type), compared from the left. Each list member is scored alone. One id beats any number of classes. Keep scores low and equal, use :where() for hooks, and order the cascade with @layer instead of weight. |