Skip to main content

CSS selectors

CSS selectors - patterns that match HTML elements by tag name, attribute, position in the DOM, or state, telling the browser which nodes to style.

Theory

TL;DR

  • Selectors work like a filter on the DOM. p grabs every paragraph. p.note grabs only paragraphs with class note. nav > ul > li grabs only direct list items inside a direct ul inside nav.
  • Basic selectors (tag, class, ID) match elements by what they are. Combinators match by where they sit in the tree.
  • Specificity score: inline = 1000, ID = 100, class = 10, tag = 1. Higher score wins. Equal scores go to the rule that appears later in the stylesheet.
  • Decision rule: class for reusable components, ID for one unique element per page, combinators to target DOM structure without adding extra classes.

Quick example

css
p { color: red; } /* tag: every p */ .note { background: yellow; } /* class: reusable across elements */ #main { font-weight: bold; } /* ID: highest non-inline specificity */ div > p { color: green; } /* child combinator: only direct p inside div */

div > p scores 0-0-0-2 (two tags). The plain p scores 0-0-0-1. Green overrides red on any p that is a direct child of a div. A p nested two levels deep stays red. That is exactly what the child combinator is for.

Selector types

Basic:

  • p (tag) - matches all elements of that tag
  • .btn-primary (class) - reusable, the standard choice for UI components
  • #header (ID) - unique per page, specificity 100
  • * (universal) - matches everything; use only in scoped resets like *, *::before, *::after { box-sizing: border-box; }

Combinators:

  • div p (descendant) - any p inside div at any nesting depth
  • div > p (child) - only direct p children of div
  • h1 + p (adjacent sibling) - the single p right after h1
  • h1 ~ p (general sibling) - all p elements after h1 at the same level

Attribute selectors:

  • [type="text"] - exact value match
  • [class~="foo"] - exact word in a space-separated list
  • [href*="cdn"] - substring match anywhere in the value
  • [src^="https"] - value starts with a string
  • [src$=".svg"] - value ends with a string

Pseudo-classes and pseudo-elements:

  • :hover, :focus, :disabled - element state
  • :nth-child(2n), :first-child - position among siblings
  • ::before, ::after - generated content before or after the element

Key difference: basic vs combinators

Basic selectors grab elements by what they are. Combinators grab elements by where they sit in the tree. Writing ul > li.active styles only top-level active list items and ignores nested li.active elements inside inner lists. Without the child combinator you would need a separate class or JavaScript to do the same thing. In my experience, switching .container p to .container > p in a real codebase removed dozens of unintended style overrides in a single pass.

When to use

  • All elements of a type - tag selector (p, h1, a)
  • Reusable UI components - class (.btn, .card, .modal)
  • One unique element per page - ID (#skip-nav for accessibility anchors)
  • DOM structure targeting - child combinator (nav > ul > li)
  • Dynamic states - pseudo-class (:hover, :focus-visible, :disabled)
  • Alternating table rows - :nth-child(2n) on tbody tr
  • Skip - deep descendant chains like div div div p; specificity becomes hard to override and matching slows on large DOMs

How the browser matches selectors

Browsers parse selectors right-to-left. For nav > ul > li.active, the engine first finds all .active elements, checks if the parent is ul, then checks if that ul is a direct child of nav. Fewer ancestor lookups means faster matching. Chrome's Blink caches matched results in StyleResolver, so repaints skip re-matching for unchanged nodes.

Specificity is stored as a three-part score (ID count, class count, tag count). When two rules target the same property on the same element, the higher score wins. Equal scores go to the later rule in the stylesheet. For a deeper look at how cascade order and specificity interact, see CSS cascade and specificity.

Common mistakes

Using descendant selector when child would do:

css
/* Matches every p inside .container at any depth */ .container p { color: blue; } /* Matches only direct p children: faster, more predictable */ .container > p { color: blue; }

The descendant version hits nested components you did not intend to style. It also makes specificity harder to override later.

Overusing ID selectors for styling:

css
/* Breaks if a second #header appears (duplicate IDs are invalid HTML) */ #header p { display: none; } /* Class composes cleanly across components */ .site-header p { display: none; }

ID specificity (100) is hard to override without another ID or !important. Classes (10) are easier to work with in large projects.

Removing focus outlines without a replacement:

css
/* Removes keyboard focus indicator, a WCAG violation */ button:hover { outline: none; } /* Shows outline only for keyboard users, not mouse clicks */ button:focus-visible { outline: 2px solid currentColor; }

Reaching for !important to fix specificity conflicts:

css
/* Creates a chain reaction: the next developer adds more !important */ p { color: red !important; } /* Add specificity through structure instead */ body .content p { color: red; }

Confusing :nth-child and :nth-of-type:

html
<ul> <div>Not a list item</div> <li>First li, second child</li> <!-- matches li:nth-child(2) --> <li>Second li, third child</li> <!-- matches li:nth-of-type(2) --> </ul>

li:nth-child(2) matches the element that is both the second child AND an li. li:nth-of-type(2) matches the second li regardless of other tags in between. Use :nth-of-type when the parent contains mixed element types.

Real-world usage

  • React / styled-components - class selectors like .btn--primary for theme variants
  • Tailwind - arbitrary selectors [data-state=open] > * for accordion panels
  • Bootstrap - combinators navbar > .container for responsive layout structure
  • Material-UI - pseudo-classes button:disabled for state-driven styles
  • Accessible UI - :focus-visible replaces :focus overrides across component libraries

Follow-up questions

Q: How do you calculate specificity for #id .class p?
A: ID = 100, class = 10, tag = 1. Total = 111. Compare to .class p which scores 11. The ID rule wins regardless of where it sits in the stylesheet.

Q: What is the performance difference between child > and descendant (space)?
A: Child checks only the immediate parent. Descendant walks the entire ancestor chain. The gap is measurable with Chrome DevTools style recalculation profiling on pages with 10,000+ nodes.

Q: How does :nth-child differ from :nth-of-type?
A: :nth-child(n) counts all siblings regardless of tag. :nth-of-type(n) counts only siblings of the same element type. Use :nth-of-type when mixed tags appear inside the same parent.

Q: Write a selector for every other row in a table body, ignoring the header.
A: tbody tr:nth-child(2n). The tbody scope excludes thead automatically, so no extra filter is needed. Works correctly on 1000-row tables without JavaScript.

Q: What is the difference between [class~="foo"] and [class*="foo"]?
A: ~= matches an exact word in a space-separated list, so class="foo bar" matches but class="foobar" does not. *= is a substring match, so both match. Use ~= when you need word-boundary precision on class names.

Examples

Basic selector cascade

html
<style> p { color: red; } /* specificity 0-0-1 */ .note { background: yellow; } /* specificity 0-1-0 */ #main { font-weight: bold; } /* specificity 1-0-0 */ div > p { color: green; } /* specificity 0-0-2 */ </style> <p>Red text.</p> <p class="note">Yellow background, red text.</p> <div> <p>Green text (child combinator beats tag selector).</p> </div> <div id="main"> <p>Green text, bold weight (two rules, different properties).</p> </div>

div > p scores 0-0-2 and beats p at 0-0-1 for the color property. The #main rule targets the div, not the p, so font-weight: bold reaches the paragraph through inheritance. That is a common source of confusion when reading devtools: the bold appears on p but the matching rule is on div.

Child combinator in a React navigation component

jsx
// Nav.jsx const Nav = () => ( <nav> <ul> <li className="active">Dashboard</li> <li>Reports</li> </ul> <ul> <li className="active">Sub-item</li> </ul> </nav> );
css
/* nav.css */ nav > ul > li.active { background: #007bff; color: white; }

Both ul elements are direct children of nav, so both li.active items match this rule. If the requirement is to style only the top navigation, add a class to the first ul instead of relying on position alone. Chaining combinators narrows the target but does not guarantee uniqueness. Know your HTML structure before writing the selector.

Specificity tiebreaker with nth-child

html
<style> li:nth-child(2) { color: red; } /* specificity 0-1-1 */ li:nth-of-type(2) { color: blue; } /* specificity 0-1-1 */ </style> <ul> <div>Not a list item</div> <!-- child 1 --> <li>First li (child 2)</li> <!-- red: matches nth-child(2) --> <li>Second li (child 3)</li> <!-- blue: matches nth-of-type(2) --> </ul>

Both rules have specificity 0-1-1. The first li is the second child overall, so :nth-child(2) matches it. The second li is the second li by type, so :nth-of-type(2) matches it. No conflict here. But in a table, tbody tr:nth-child(2) starts counting from the first tr inside tbody, not from the top of the entire table. Scoping to tbody resets the child index. That is the kind of detail that comes up in senior interviews.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?