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.
pgrabs every paragraph.p.notegrabs only paragraphs with classnote.nav > ul > ligrabs only direct list items inside a directulinsidenav. - 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
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) - anypinsidedivat any nesting depthdiv > p(child) - only directpchildren ofdivh1 + p(adjacent sibling) - the singlepright afterh1h1 ~ p(general sibling) - allpelements afterh1at 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-navfor accessibility anchors) - DOM structure targeting - child combinator (
nav > ul > li) - Dynamic states - pseudo-class (
:hover,:focus-visible,:disabled) - Alternating table rows -
:nth-child(2n)ontbody 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:
/* 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:
/* 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:
/* 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:
/* 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:
<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--primaryfor theme variants - Tailwind - arbitrary selectors
[data-state=open] > *for accordion panels - Bootstrap - combinators
navbar > .containerfor responsive layout structure - Material-UI - pseudo-classes
button:disabledfor state-driven styles - Accessible UI -
:focus-visiblereplaces:focusoverrides 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
<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
// Nav.jsx
const Nav = () => (
<nav>
<ul>
<li className="active">Dashboard</li>
<li>Reports</li>
</ul>
<ul>
<li className="active">Sub-item</li>
</ul>
</nav>
);/* 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
<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 readyA concise answer to help you respond confidently on this topic during an interview.