CSS Selector Reference

A CSS selector is the part of a rule that tells the browser which elements to style. Everything you do in CSS starts here: before the browser can apply a single declaration, it has to figure out which nodes in the DOM tree match the pattern you wrote. Selectors range from trivially simple (target every <p>) to highly specific (target the third list item inside a nav that isn’t disabled and is currently hovered). Mastering the full selector vocabulary—and understanding how the browser scores and applies competing selectors—is what separates CSS that ‘mostly works’ from CSS you can predict and control.

This lesson is a comprehensive reference: every category of selector, how matching and specificity actually work under the hood, worked examples, and the mistakes almost everyone makes at least once.

Overview: How Selector Matching Works

A CSS rule is a selector paired with a declaration block: selector { property: value; }. When the browser builds the render tree, it needs to know, for every element, which declarations apply to it. It does this by testing each rule’s selector against each element.

An important implementation detail: browsers typically match selectors right to left, not left to right. For a selector like nav ul li a, the engine first finds all <a> elements (the rightmost, or ‘key’ selector), then walks up the ancestor chain checking whether each candidate has an <li> ancestor, inside a <ul>, inside a <nav>. This is faster than scanning every <nav> and walking down, because most elements can be rejected immediately by their own tag name, class, or attributes without ever inspecting ancestors. This is also why very ‘loose’ descendant selectors (like * .item) can be more expensive to match than a tight, specific one.

Selectors fall into a few families: simple selectors (type, class, ID, universal, attribute), combinators that relate two simple selectors through the document tree (descendant, child, sibling), and pseudo-classes/pseudo-elements that match based on state or generate/target sub-parts of an element. A full selector is usually a chain of these—called a compound selector when there’s no combinator, or a complex selector when combinators are used.

Syntax

The general shape of a rule is:

selector-part1 combinator selector-part2 { property: value; property2: value2; }
  • Simple selector — a single pattern like p, .card, #nav, [type="text"], or *.
  • Compound selector — simple selectors glued together with no space, e.g. a.button:hover means ‘an <a> that has class button and is currently hovered.’
  • Combinator — a symbol (or whitespace) joining two compound selectors to express a tree relationship: descendant (space), child (>), adjacent sibling (+), general sibling (~).
  • Selector list — multiple selectors separated by commas share the same declaration block, e.g. h1, h2, h3 { margin-top: 0; }.
Selector Matches Example
* Every element * { box-sizing: border-box; }
type All elements of that tag p { line-height: 1.5; }
.class Elements with that class .card { padding: 1rem; }
#id The element with that ID #main { max-width: 960px; }
[attr] Elements with the attribute present [disabled] { opacity: 0.5; }
[attr=value] Exact attribute value match [type="radio"]
[attr^=value] Attribute value starts with [href^="https"]
[attr$=value] Attribute value ends with [src$=".png"]
[attr*=value] Attribute value contains [class*="col-"]
A B B is a descendant of A ul li
A > B B is a direct child of A ul > li
A + B B immediately follows A (same parent) h2 + p
A ~ B B follows A anywhere among siblings h2 ~ p
:pseudo-class State or position, e.g. :hover, :nth-child() li:first-child
::pseudo-element A sub-part of an element p::first-line

Examples

Example 1: Combinators

Suppose you have a navigation list: a <nav> containing a <ul> with several <li> items, each holding a link.

nav ul li {
  color: #333;
}

nav > ul {
  border-bottom: 2px solid #cccccc;
}

li + li {
  margin-left: 8px;
}

li ~ li {
  font-weight: 500;
}

Result: Every list item’s text turns dark gray (matched via the descendant combinator, since <li> is nested several levels inside <nav>). The <ul> itself gets a thin gray bottom border, but only because it’s a direct child of <nav> — if the markup had an extra wrapping <div> between them, nav > ul would no longer match. Every <li> except the first gets 8px of left margin (adjacent sibling), and every <li> except the first is also bolded (general sibling), since each one after the first has at least one preceding sibling <li>.

Example 2: Attribute Selectors and Pseudo-classes

input[type="email"] {
  border: 1px solid #0a84ff;
}

a[href^="https://"] {
  color: #1a7f37;
}

a[href$=".pdf"]::after {
  content: " (PDF)";
  font-size: 0.8em;
  color: #666666;
}

li:nth-child(odd) {
  background-color: #f5f5f5;
}

button:not(:disabled):hover {
  cursor: pointer;
  background-color: #eeeeee;
}

Result: Email inputs get a blue border, distinguishing them from other input types without needing extra classes. Links whose href starts with the secure scheme are colored green. Any link ending in .pdf automatically gets the literal text ‘ (PDF)’ appended after it, in smaller gray type, generated purely by CSS via ::after. Odd-numbered list items (1st, 3rd, 5th…) get a light gray background, producing a striped-table effect. Buttons get a hover background only when they are not disabled, thanks to the :not() pseudo-class excluding the disabled state.

Example 3: A Realistic Combined Component

.card-list {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

.card-list > .card {
  border: 1px solid #dddddd;
  border-radius: 8px;
  padding: 1rem;
}

.card-list > .card:nth-child(3n) {
  border-color: #0a84ff;
}

.card-list > .card.featured:not(.archived) {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.card-list > .card:hover {
  transform: translateY(-2px);
}

Result: Cards are laid out in a three-column grid with consistent gutters. Every direct-child card gets a light gray rounded border and padding. Every third card (3rd, 6th, 9th…) instead gets a blue border, useful for visually breaking up long rows. Any card that has both the featured class and does not have the archived class gets a drop shadow, while hovering over any card lifts it slightly with a transform. Notice how the specificity and combinator choices stack: .card-list > .card.featured:not(.archived) is deliberately narrow so it only ever affects the intended subset.

Under the Hood: Specificity and the Cascade

When two rules target the same element with conflicting declarations, the browser must decide which one wins. This is governed by specificity, calculated as a four-part value, often written (a, b, c, d):

  • a — 1 if the declaration is inline (a style attribute), else 0. (Inline styles are outside this lesson’s scope since we focus on stylesheet selectors, but they always outrank everything below except !important.)
  • b — the count of ID selectors (#id).
  • c — the count of class selectors, attribute selectors, and pseudo-classes (.class, [type], :hover).
  • d — the count of type selectors and pseudo-elements (div, ::before).

The universal selector (*), combinators (>, +, ~, and the descendant space), and the negation pseudo-class :not() itself add nothing to specificity—though whatever selector is placed inside :not() still counts. So .card:not(.archived) has one class from .card and one class from .archived, for a total of two class-level points.

To compare two selectors, compare (b, c, d) left to right like a version number: any ID beats any number of classes; any number of classes beats any number of type selectors. If specificity ties, the rule that appears later in the stylesheet (or later in document order across stylesheets) wins — this is the ‘cascade’ part of CSS. An !important flag on a declaration overrides normal specificity entirely (except against another !important, where specificity rules resume).

Concretely: #nav .item has specificity (0,1,1,0). .nav .list .item has (0,0,3,0). Since b=1 beats b=0, the ID-containing selector wins regardless of how many classes the other one stacks — this is exactly why heavy ID usage causes so many ‘why won’t my CSS override this’ headaches.

Common Mistakes

Mistake 1: Reaching for IDs to win specificity fights.

#header .logo {
  color: red !important;
}

This ‘wins’ immediately, but it does so by combining two blunt instruments — an ID selector and !important — which makes the color nearly impossible to override later without another !important or another ID. The fix is to keep specificity low and consistent, and let source order do the work:

.site-header .logo {
  color: red;
}

Mistake 2: Confusing the descendant combinator with the child combinator.

Given nested lists like a <ul> containing <li> items, some of which contain their own nested <ul>:

.menu li {
  padding: 4px 0;
}

This descendant selector styles every <li> anywhere inside .menu, including items in nested sub-menus, which is often not intended. If only the top-level items should get that padding, use the child combinator instead:

.menu > li {
  padding: 4px 0;
}

Mistake 3: Forgetting that attribute selector values are case-sensitive by default (for the value, not the attribute name).

a[href$=".PDF"] {
  color: green;
}

If actual file links use lowercase .pdf, this rule silently matches nothing. Add the case-insensitive flag i before the closing bracket:

a[href$=".pdf" i] {
  color: green;
}

Best Practices

  • Prefer classes over IDs for styling; reserve IDs for JavaScript hooks, fragment links, and form label associations.
  • Keep selectors as short and flat as reasonably possible — deep chains like .page .content .sidebar .widget ul li a are fragile and hard to override.
  • Use the child combinator (>) when you specifically mean ‘direct child,’ not just ‘somewhere inside.’
  • Group unrelated elements that share styling with a selector list (commas) instead of duplicating declaration blocks.
  • Use attribute selectors for semantic HTML hooks ([type="checkbox"], [aria-expanded="true"]) instead of adding redundant classes.
  • Avoid !important in component stylesheets; reserve it for narrow utility classes or overriding third-party CSS you can’t edit.
  • When two rules should never conflict, keep their specificity roughly equal and let source order (later wins) resolve ties predictably.

Practice Exercises

Exercise 1: Given a <table> with rows, write a selector that gives every even row a light gray background, without adding any class to the markup.

Exercise 2: Write two rules — one using an ID and one using two classes — that would conflict on the same element’s color property. Determine, using the (a,b,c,d) method from this lesson, which one would win, and explain why.

Exercise 3: You have a list of links, some of which point to external sites (starting with https://) and some of which are internal (starting with /). Write a selector that adds a small icon (via content in a pseudo-element) only after external links.

Summary

  • Selectors determine which elements a rule applies to; browsers match them right-to-left starting from the rightmost ‘key’ selector.
  • Simple selectors (type, class, ID, attribute, universal) combine into compound selectors, and combinators (space, >, +, ~) express relationships between them in the document tree.
  • Pseudo-classes (:hover, :nth-child(), :not()) match based on state or structural position; pseudo-elements (::before, ::after, ::first-line) target generated content or sub-parts of an element.
  • Specificity is calculated as (inline, ID count, class/attribute/pseudo-class count, type/pseudo-element count) and determines which conflicting rule wins; ties are broken by source order.
  • Favor low, consistent specificity built from classes and attribute selectors over IDs and !important, which are hard to override later.