Selectors & Specificity

This section documents general HTML5 and CSS concepts — it is not tied to any specific framework or library. This content was generated with the assistance of AI. Verify it against current MDN documentation and browser-support tables (caniuse.com) before relying on it in production, since HTML/CSS features and browser support continue to evolve.

CSS selectors are how a stylesheet finds the elements it wants to style, ranging from a bare element type to combinations that key off attributes, position among siblings, or interactive state. When more than one rule targets the same element, the browser needs a way to decide which one wins — that mechanism is specificity. This page works through the full range of selector types (element, class, ID, universal, attribute, pseudo-class, pseudo-element, and the combinators that relate elements to one another), specificity and !important, and then a "modern selectors" section covering native CSS nesting, :has(), cascade layers, and scoped styles. Custom properties and media-query selection are covered separately in CSS Custom Properties and Media Queries, and the box model referenced by several examples below is covered in The Box Model.

Element, Class, and ID Selectors

Three selectors are used more than any other:

  • Element type — selects every instance of a tag. For example, p selects all <p> elements. Other examples are h1, ul, and div.

  • Class (.) — selects elements carrying a given class attribute. Given <h1 class="heading">Heading</h1>, the .heading selector targets it. A class can be reused on any number of elements.

  • ID (#) — selects the single element carrying a given id attribute. Given <div id="login"><!-- login content -→</div>, the #login selector targets it. Since an id must be unique within a document, this selector matches at most one element.

p { color: #333; }              /* element */
.heading { font-weight: bold; } /* class */
#login { border: 1px solid; }   /* ID */

The Universal Selector (*)

The universal selector, *, matches every element in the document. It is most often seen paired with the inherit keyword to push a value down onto every descendant of a common ancestor — the canonical example being the box-sizing reset also shown in web/html-css/box-model.adoc#_box-sizing:

html {
  box-sizing: border-box;
}
*, *:before, *:after {
  box-sizing: inherit;
}

Here, box-sizing: border-box is set once on html and every element (and its :before/:after pseudo-elements) inherits it via the universal selector.

Attribute Selectors

Attribute selectors, written in square brackets, match on the presence or value of an HTML attribute:

Syntax Matches

[attribute]

Any element with that attribute present, regardless of value — [href] matches every element with an href attribute.

[attribute=value]

An exact value match — [lang="en"] matches elements with lang set to exactly en.

[attribute^=value]

A value that begins with the given string — [href^="https://"] matches links to secure URLs.

[attribute$=value]

A value that ends with the given string — [href$=".com"] matches links ending in .com.

[attribute*=value]

A value containing the given string anywhere — [href*="co.uk"] matches both http://www.example.co.uk?test=true and https://www.example.co.uk.

Pseudo-Classes

A pseudo-class selects an element based on a state it is currently in, rather than anything present in the markup itself. The syntax is a single colon (:) followed by a keyword.

The most common introduction to pseudo-classes is styling the four states of an anchor element:

  • :link — applied whenever an anchor has an href attribute.

  • :visited — applied once the link has been visited.

  • :hover — applied while the user is hovering over the link.

  • :active — applied while the link is being clicked.

a:link, a:visited {
  color: deepskyblue;
  text-decoration: none;
}
a:hover, a:active {
  color: hotpink;
  text-decoration: dashed underline;
}

The order these rules are written in matters, because later rules of equal specificity override earlier ones in the cascade. Writing a:hover before a:link, for example, would cause the :link rule to win and the hover effect would never be visible. The mnemonic for the correct order is LoVe HAte: Lo(:link), V(:visited), H(:hover), A(:active).

Interactive and structural pseudo-classes

Beyond link states, some of the most useful pseudo-classes are:

  • Interactive state: :checked (a checked checkbox/radio/option), :disabled (a disabled form control), :focus (the element currently holding keyboard focus).

  • Structural (position among siblings): :first-child, :last-child, :nth-child(), :nth-last-child(), :first-of-type, :last-of-type, :nth-of-type(), :nth-last-of-type().

:nth-child() accepts either a keyword (odd, even) or a functional notation such as 3n - 1, giving a lot of flexibility for styling repeating patterns:

li:nth-child(3n - 1) {
  background: skyblue;
  color: white;
  font-weight: bold;
}
li:nth-child(3n) {
  background: deepskyblue;
  color: white;
  font-weight: bolder;
}

Applied to a plain <ul> of seven <li> items, this colors every third item (starting from the 2nd) skyblue, and every third item after that (the 3rd, 6th, …​) deepskyblue, leaving the rest unstyled.

Pseudo-Elements

A pseudo-element, written with a double colon (::), selects a part of an element rather than the whole element, letting you add stylistic content without adding markup to the HTML document. The available pseudo-elements are ::before, ::after, ::first-letter, ::first-line, ::selection, and ::backdrop.

h1::first-letter {
  font-size: 5rem; /* an oversized "drop cap" on the first letter of every h1 */
}

Because pseudo-elements have no semantic value and are purely presentational, they should be used with care — they are ideal for decorative content (the < / > brackets around a label, a custom text-selection highlight color via ::selection, or the backdrop behind a <dialog> via ::backdrop) but should never carry content a user actually needs to read, since it is invisible to screen readers and to copy/paste.

Combining Selectors: Combinators

Selectors can be combined to refine a selection, and combinators express a relationship between two selectors based on the elements' position in the document tree:

Combinator Syntax Selects

Descendant

ul li

Every li element nested anywhere inside a ul, at any depth.

Child

ul.primary > li

Only the li elements that are direct children of ul.primary — not any li nested deeper.

Adjacent sibling

li.selected + li

The single li element that is the next sibling immediately after an li.selected.

General sibling

li.selected ~ li

Every li element that follows an li.selected as a sibling, not just the immediate next one.

Selectors can also be combined without whitespace to intersect two conditions on the same element — for example, li.primary selects only the li elements that also carry the primary class.

Adjacent sibling (+) vs. general sibling (~)

The distinction between + and ~ is easiest to see against a single list where one item carries a .selected class:

li.selected + li {
  background: deepskyblue;
  color: white;
  font-weight: bolder;
}

li.selected ~ li {
  background: deepskyblue;
  color: white;
  font-weight: bolder;
}
flowchart TB subgraph adjacent["li.selected + li (adjacent sibling)"] direction LR A1[Item 1] --> A2[Item 2] --> A3[Item 3] --> A4[Item 4 - .selected] --> A5["Item 5 - MATCHED"] --> A6[Item 6] --> A7[Item 7] end subgraph general["li.selected ~ li (general sibling)"] direction LR G1[Item 1] --> G2[Item 2] --> G3[Item 3] --> G4[Item 4 - .selected] --> G5["Item 5 - MATCHED"] --> G6["Item 6 - MATCHED"] --> G7["Item 7 - MATCHED"] end

With +, only Item 5 matches — it is the single element immediately following .selected. With ~, every later sibling (5, 6, and 7) matches, because ~ matches any following sibling, not just the adjacent one. Combining a sibling combinator with a structural pseudo-class narrows this further — for example, li.selected ~ li:nth-child(odd) selects only the odd-numbered items after the selected one.

CSS Specificity

When two rules target the same element and set the same property, specificity decides which one the browser applies. Specificity is commonly represented as a 4-value notation, a comma-separated list of integers where the leftmost value is the most significant:

Position Counts

1st (highest)

Inline style attributes.

2nd

ID selectors (#id).

3rd

Class selectors (.class), attribute selectors ([attr]), and pseudo-classes (:hover).

4th (lowest)

Element type selectors (h1, p, …​) and pseudo-elements (::before).

An inline style scores (1, 0, 0, 0). An ID selector alone scores (0, 1, 0, 0). A class selector alone scores (0, 0, 1, 0). A bare element selector such as h1 scores (0, 0, 0, 1).

Worked examples

  • li.selected a[href] has two element selectors (li, a), one class selector (.selected), and one attribute selector ([href]) — specificity (0, 0, 2, 2).

  • #newItem #mainHeading span.smallPrint has two ID selectors, one class selector (.smallPrint), and one element selector (span) — specificity (0, 2, 1, 1).

Comparing the two, (0, 2, 1, 1) beats (0, 0, 2, 2) because the comparison works left to right: as soon as one selector has a higher count at a given position, it wins outright, regardless of what follows. Two IDs in the second selector settle the comparison before the class/element counts are even considered.

The special case of !important

The !important keyword can be appended to any CSS declaration’s value. It effectively sets that declaration’s specificity to (1, 0, 0, 0, 0) — a rank above even inline styles — so it takes precedence over any other rule for that property, regardless of selector specificity.

div.media {
  display: block;
  width: 100%;
  float: left;
}
.hide {
  display: none;
}

Given <div class="media hide">, it might look like .hide should win because it is declared second. But by the specificity rules above, div.media scores (0, 0, 1, 1) while .hide scores only (0, 0, 1, 0) — so div.media’s `display: block overrides .hide’s `display: none, and the element is not hidden. Adding !important fixes this:

.hide {
  display: none !important;
}

This makes .hide reliably win regardless of what else targets the same element, which is exactly why !important is reserved for small, single-purpose utility classes like this one — overusing it forces every future override to reach for !important as well, defeating the purpose of specificity in the first place.

Modern Selectors

CSS has recently gained several features that change how selectors are organized and composed, rather than adding new individual selector syntax: native nesting, the :has() relational selector, cascade layers, and scoped styles.

Native CSS nesting

Nesting lets you group related selectors the way Sass has long allowed, instead of repeating a shared prefix on every line:

/* traditional, flat selectors */
.menu {}
.menu li {}
.menu li a {}

/* the same relationship, nested */
.menu {
  li {
    a {
      /* styles for anchor elements inside list items */
    }
  }
}

A child selector can also be written explicitly with the & nesting selector, which stands for "the parent selector" and is required when the nested selector doesn’t start with a combinator, a class, or an ID (for example, when combining with a pseudo-class):

.article {
  font-family: Arial, sans-serif;

  & .title {
    font-size: 2rem;
    font-weight: bold;
  }
}

.button {
  background-color: blue;
  color: white;

  &:hover {
    background-color: darkblue;
  }
}

Nesting also supports at-rules such as media and container queries directly inside a rule, instead of wrapping the whole rule in a separate block:

.sidebar {
  width: 100%;
  padding: 1rem;

  @media (min-width: 600px) {
    width: 250px;
  }
}

Because the nested styles live alongside the component they belong to, removing a component’s markup lets you delete its entire style block in one place, instead of hunting for related selectors scattered elsewhere in the stylesheet.

The :has() relational selector

:has() is a functional pseudo-class that selects an element if any of the relative selectors passed to it match at least one element within (or, via a leading combinator, relative to) it — effectively a "parent selector" or "previous sibling selector", which CSS previously had no way to express.

/* select an h2 that is immediately followed by an img, and style the h2 itself */
h2:has(+ img) {
  color: red;
}

/* style a .card based on a property of one of its own children */
.card:has(.highlight) {
  border: 2px solid #ff9800;
  background-color: #fff8e1;
}

Given

<div class="card">
  <h2>Card Title 1</h2>
  <p>This is a regular paragraph without highlighting.</p>
</div>
<div class="card">
  <h2>Card Title 2</h2>
  <p class="highlight">This paragraph is highlighted with special styling.</p>
</div>

only the second .card gets the border and background, because only it contains a .highlight descendant. :has() cannot be nested inside another :has(), and pseudo-elements cannot appear as selectors within it or serve as its anchor.

AND / OR composition

:has() also accepts more than one condition:

  • OR: comma-separated selectors inside a single :has() match if any of them are present — x:has(a, b) styles x if it contains either an a descendant or a b descendant.

  • AND: chaining multiple :has() calls matches only if all of them are present — x:has(a):has(b) styles x only if it contains both an a descendant and a b descendant.

Cascade layers (@layer)

@layer addresses the specificity problem from a different angle: instead of relying on selector specificity alone, you explicitly declare named layers, and layer order takes priority over selector specificity when resolving conflicts between layers.

@layer base {
  a {
    font-weight: bold;
    color: black; /* ignored: theme layer takes precedence */
  }
  .nav-link {
    color: blue; /* ignored: theme layer takes precedence */
  }
}

@layer theme {
  a {
    color: purple; /* wins: styles all links */
  }
}

@layer special {
  .accent {
    color: orange; /* wins: styles all .accent elements */
  }
}

Even though .nav-link has higher selector-level specificity than the bare a selector, the theme layer’s a { color: purple } still wins over the base layer’s .nav-link { color: blue }, because layer order is consulted before specificity. Layer precedence is determined by the order in which layer names are first declared — you can reverse it explicitly:

@layer special, theme, base;

With this ordering, the base layer now takes precedence over theme, so links revert to black (and .nav-link ones to blue) — adjusting layer order is therefore a way to manage which whole groups of styles take precedence, without touching individual selectors.

Scoped styles (@scope)

@scope limits where a set of CSS rules applies, encapsulating a rule so it only affects a specific subtree instead of the whole document — addressing the same problem naming conventions (like BEM) and CSS-in-JS libraries have traditionally solved without native browser support.

@scope (.card) {
  .title {
    font-weight: bold;
  }
}

Here, .title only matches inside a .card, so it can’t clash with unrelated .title elements elsewhere on the page. The full syntax also accepts a lower bound:

@scope (<scope-start>) [to (<scope-end>)] {
  /* rules go here */
}

<scope-start> is the scoping root (the upper bound); the optional to (<scope-end>) narrows the scope further by excluding everything from the named lower-bound selector downward:

@scope (.media-object) to (.content > *) {
  img {
    border-radius: 50%;
  }
  .content {
    padding: 1em;
  }
}

img only matches images within a .media-object, and .content is styled, but styling stops at the .content > * boundary — the direct children of .content are excluded from the scope, so nested components inside .content are free to define their own, unrelated styles without being caught by this rule.