1. Presentation
  2. CSS
  3. Selectors
  4. Selectors and Specificity

Presentation · CSS 02

Selectors and Specificity

A selector is a question asked of the document tree: which elements should this rule style? Learn to ask it precisely, and to ask it with as little force as possible, and most of the trouble people have with CSS never starts.

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]:hover means a link element, that has an href, 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 parts of a selectorThe selector nav > a[href^="/"]:hover. The word nav is a type selector and forms one compound. The greater-than sign is a child combinator. The a, the attribute selector [href^="/"] and the pseudo-class :hover together form a second compound, all of which must match the same element. An arrow underneath runs from right to left, because the browser matches the rightmost compound first and then checks its context.compoundcompound: the subjectnav>a[href^="/"]:hovertypetypeattributepseudo-classcombinatormatched from the right
Simple selectors combine into compounds; a combinator links compounds into a path through the tree.

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.

SelectorExampleMatches
Universal*Every element
Typearticle, my-cardElements with that tag name, built in or custom
Class.warningElements whose class attribute contains that word
Id#introThe 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:

aside { padding: var(--space); border-inline-start: 0.25em solid var(--accent); &.warning { --accent: var(--danger); } }

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 #homeNavbar styles one element on one page, and the next page gets #subNavbar and 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.

SyntaxMatches when the attributeExample
[attr]is present, with any valueimg[alt]
[attr="v"]is exactly vinput[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 va[href^="https:"]
[attr$="v"]ends with va[href$=".pdf"]
[attr*="v"]contains v anywherea[href*="ucsd.edu"]
[attr="v" i]matches, ignoring casea[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:

a { &[href$=".pdf" i]::after { content: " (PDF)"; } &[href^="mailto:"]::before { content: "Email: "; } } img:not([alt]) { outline: 0.25rem dashed var(--danger); }

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.

CombinatorNameMatches B when it is
A B (a space)Descendantanywhere inside A, at any depth
A > BChilda direct child of A
A + BNext siblingimmediately after A, with the same parent
A ~ BSubsequent siblinganywhere 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:

article > p compared with article pThe same tree is drawn twice. An article element has four children: h2, p, section and p. The section has one child, another p. On the left, under the selector article > p, only the two p elements that are direct children of the article are highlighted; the p inside the section is faded. On the right, under the selector article p, all three p elements are highlighted, including the one inside the section.article > particleh2psectionppchildren only: 2 of the 3 paragraphsarticle particleh2psectionppdescendants at any depth: all 3
The child combinator stops one level down; the descendant combinator reaches every level.

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:

article { max-inline-size: var(--measure); p { line-height: 1.6; } > h2 + p { font-size: 1.125rem; } figure ~ p { margin-block-start: calc(var(--space) * 2); } }

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:

<p>Before you start: <ul> <li>Install Node</li> </ul> </p>

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-classMatches when
:link, :visiteda link has not, or has, been visited
:any-linkan element is a link, visited or not
:hoverthe pointer is over the element
:activethe element is being activated, such as a pressed button
:focusthe element has focus, by any means
:focus-visiblethe element has focus and the browser judges that a focus indicator should show, chiefly after keyboard use
:focus-withinthe element or anything inside it has focus
:targetthe 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:

a { color: var(--link); &:visited { color: var(--link-visited); } &:where(:hover, :focus-visible) { text-decoration-thickness: 0.15em; } &:active { color: var(--link-active); } }

: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-visible exists 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-classMatches
:checked, :indeterminatecheckboxes, radio buttons and options in that state
:defaultthe default option, checkbox or submit button
:enabled, :disabledcontrols that can or cannot be used
:required, :optionalcontrols with and without the required attribute
:valid, :invalidcontrols whose value does or does not meet its constraints, from the start
:user-valid, :user-invalidthe same, but only after the user has interacted with the control
:in-range, :out-of-rangenumber and date inputs inside or outside their min and max
:read-only, :read-writeelements the user cannot, or can, edit
:placeholder-showninputs 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:

input:user-invalid { border-color: var(--danger); outline: 1px solid var(--danger); }

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-classMatches
:rootthe root element, html in an HTML document
:emptyan element with no children at all, not even text
:first-child, :last-child, :only-childthe first, last or only child of its parent
:first-of-type, :last-of-type, :only-of-typethe 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), or 2n+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 after of, 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.

table tbody tr:nth-child(even of :not([hidden])) { background: var(--surface-alt); } ul > li:not(:last-child) { margin-block-end: 0.5em; } p:empty { display: none; }

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-elementTargets
::before, ::aftera generated box at the start or end of the element's content
::first-letter, ::first-linethe first letter, or the first rendered line, of a block
::markerthe bullet or number of a list item
::placeholderthe placeholder text in an input
::selectionthe text the user has selected
::file-selector-buttonthe button inside <input type="file">
::backdropthe 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:

q { &::before { content: open-quote; } &::after { content: close-quote; } } dt::after { content: ":"; } li::marker { color: var(--accent); }

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-classMatches whenIts specificityList is
:is(A, B)any argument matchesits most specific argumentforgiving
:where(A, B)any argument matchesalways zeroforgiving
:not(A, B)no argument matchesits most specific argumentnot forgiving
:has(A)something relative to the element matches Aits most specific argumentnot forgiving

:is(), for shorter lists

:is() factors out the part of a selector list that varies. These two rules match the same elements:

article h2, article h3, article h4, aside h2, aside h3, aside h4 { text-wrap: balance; } :is(article, aside) :is(h2, h3, h4) { text-wrap: balance; }

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:

input:is(:user-invalid, :future-state) { border-color: var(--danger); }

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:

:where(article, aside) :where(h2, h3) { margin-block: 1.5em 0.5em; line-height: 1.2; } nav :where(a[aria-current="page"]) { font-weight: bold; }

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:

figure:has(figcaption) { padding-block-end: var(--space); } form:has(:user-invalid) [type="submit"] { opacity: 0.6; } h2:has(+ p) { margin-block-end: 0.25em; } body:has(dialog[open]) { overflow: hidden; }

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.

Specificity as three columns: id, class, typeSix selectors with their specificity counted in three columns. p scores 0, 0, 1. article > p scores 0, 0, 2. a:hover scores 0, 1, 1. nav a[aria-current] scores 0, 1, 2. :where(nav) a scores 0, 0, 1, because :where contributes nothing. The id selector main p scores 1, 0, 1, and its id column is marked red: a single id outweighs any number of classes.selectoridclasstypep001article > p002a:hover011nav a[aria-current]012:where(nav) a001#main pavoid101compare left to right: the first column that differs decides
Specificity counts ids, then classes, attributes and pseudo-classes, then types and pseudo-elements.

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.

#sidebar ul li a { color: navy; } /* (1, 0, 3) */ .page #sidebar ul li a.active { color: red; } /* (1, 2, 3) */ .special { color: green !important; } /* wins by force */

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 a is clearer and weaker than body 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 @layer beats 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.
@layer base, components; @layer base { nav ul a { color: var(--link); } } @layer components { :where(nav) a[aria-current="page"] { color: var(--accent); } }

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

ConceptKey points
Selectors as queriesA 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 selectorsType 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 treeSpace for descendants, > for children, + and ~ for later siblings. Nesting writes the same selectors. CSS matches the repaired tree, so malformed markup breaks selectors.
Pseudo-classesMatch 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-elementsTwo 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.