CSS Selector Tester Guide: Specificity, Combinators & nth-child
CSS selectors power both styling and DOM querying. Whether you are writing a scraper with document.querySelectorAll, debugging a specificity conflict in a stylesheet, or mastering the subtleties of :nth-child, understanding how the browser evaluates selectors will save you from hours of trial and error. Use the CSS Selector Tester to run every example in this guide against live HTML instantly.
This guide covers the four areas that trip up developers most: the specificity calculation algorithm, CSS combinators, the :nth-child vs :nth-of-type mismatch, and attribute selectors — plus a comparison with XPath for scraping tasks.
Specificity: The (a, b, c) Tuple
When two selectors match the same element and set the same property, the browser applies the one with higher specificity. Specificity is calculated as a three-component tuple (a, b, c):
- a — ID selectors. Each
#idin the selector adds 1 to a. - b — Class, attribute, and pseudo-class selectors. Each
.class,[attr], or:hoveradds 1 to b. - c — Type selectors and pseudo-elements. Each element name like
divor::beforeadds 1 to c.
The universal selector *, combinators (>, +, ~, space), and the :where() pseudo-class contribute zero to specificity. Inline styles have specificity (1, 0, 0, 0) — treated as a fourth component that beats any selector. !important overrides everything and should be avoided in production code.
| Selector | a | b | c | Total |
|---|---|---|---|---|
* | 0 | 0 | 0 | (0,0,0) |
p | 0 | 0 | 1 | (0,0,1) |
p.note | 0 | 1 | 1 | (0,1,1) |
ul li.active | 0 | 1 | 2 | (0,1,2) |
#nav a | 1 | 0 | 1 | (1,0,1) |
#nav .item:hover | 1 | 2 | 0 | (1,2,0) |
Tuples are compared left to right: a selector with a=1 always wins over one with a=0, regardless of how large b and c are. There is no integer overflow — 11 classes do not beat one ID.
/* (0,0,1) — lowest */
p { color: black; }
/* (0,1,1) — wins over the above */
p.note { color: navy; }
/* (1,0,1) — wins over both */
#content p { color: darkgreen; }
/* Inline style — wins over all selectors */
/* <p style="color: red"> */Combinators: Four Ways to Traverse the DOM
Combinators describe the structural relationship between two simple selectors. There are four of them, and choosing the right one tightens your selector and avoids unintended matches.
- Descendant (space).
article pmatches every<p>that is a descendant of<article>at any depth. Convenient but broad — it matches deeply nested paragraphs you may not intend to select. - Child (
>).ul > limatches only<li>elements that are direct children of a<ul>. Nested lists are excluded. - Adjacent sibling (
+).h2 + pmatches the first<p>immediately following an<h2>at the same level. Useful for styling the lead paragraph after a heading. - General sibling (
~).h2 ~ pmatches all<p>siblings that come after an<h2>, not just the first one.
/* Descendant: any <a> inside .nav */
.nav a { text-decoration: none; }
/* Child: only direct <li> children */
.menu > li { display: inline-block; }
/* Adjacent sibling: <p> immediately after <h2> */
h2 + p { font-size: 1.1em; font-weight: 500; }
/* General sibling: all <p> after a <details> */
details ~ p { margin-top: 0.5rem; }A common mistake is using the descendant combinator when the child combinator is intended. Consider a nested navigation menu: .nav a styles links at every depth, while .nav > li > a styles only the top-level links, leaving sub-menu links unstyled.
:nth-child() vs :nth-of-type() — The Classic Mismatch
Both pseudo-classes accept the An+B formula (e.g., :nth-child(2n+1) for odd elements), but they count differently and the difference catches developers off guard when element types are mixed.
:nth-child(n) counts all siblings regardless of tag name, then checks whether the element at that position also matches the rest of the selector. :nth-of-type(n) counts only siblings of the same tag name.
<section>
<h2>Title</h2> <!-- child 1 -->
<p>First para.</p> <!-- child 2, p:nth-of-type(1) -->
<p>Second para.</p> <!-- child 3, p:nth-of-type(2) -->
<p>Third para.</p> <!-- child 4, p:nth-of-type(3) -->
</section>/* Matches child #2 overall — the first <p> — because
child 1 is the <h2>, not a <p>. Works as expected here. */
p:nth-child(2) { color: steelblue; }
/* Matches the first <p> counted among <p> siblings only.
Same result here, but diverges when the <h2> is removed. */
p:nth-of-type(1) { color: steelblue; }
/* :nth-child(1) would match the <h2>, NOT a <p>,
so "p:nth-child(1)" matches nothing in this markup. */
p:nth-child(1) { color: red; } /* no match */
/* Highlight every other <p> starting from the first */
p:nth-of-type(odd) { background: #f5f5f5; }The rule of thumb: use :nth-of-type when the parent contains a mix of element types (headings, paragraphs, figures) and you only want to count one type. Use :nth-child when all siblings are the same tag (a pure list of <li> or <td> elements) to avoid surprises.
Attribute Selectors
Attribute selectors let you match elements by the presence or value of an HTML attribute without adding extra classes. They are invaluable in scrapers and in styling third-party markup you cannot modify.
| Syntax | Matches | Example |
|---|---|---|
[attr] | Element has the attribute (any value) | [data-tooltip] |
[attr="val"] | Exact value match | [type="submit"] |
[attr^="val"] | Value starts with val | [href^="https"] |
[attr$="val"] | Value ends with val | [href$=".pdf"] |
[attr*="val"] | Value contains val anywhere | [class*="btn-"] |
[attr~="val"] | Whitespace-separated list contains val | [class~="active"] |
[attr|="val"] | Equals val or starts with val- | [lang|="en"] |
[attr="val" i] | Case-insensitive match | [type="TEXT" i] |
/* Style all external links */
a[href^="http"]:not([href*="yourdomain.com"]) {
padding-right: 1em;
background: url('/icons/external.svg') no-repeat right center;
}
/* Highlight required fields */
input[required] {
border-left: 3px solid tomato;
}
/* Target data attributes set by JavaScript */
[data-state="loading"] .spinner { display: block; }
[data-state="ready"] .spinner { display: none; }
/* Match PDF download links */
a[href$=".pdf"]::after {
content: " (PDF)";
font-size: 0.8em;
color: gray;
}Note that [class*="btn-"] is not equivalent to the class selector .btn-primary. The attribute selector performs a raw substring match on the entire attribute string, so it will also match class="my-btn-primary". For precise class matching without JavaScript, prefer [class~="btn-primary"] or the plain class selector.
Testing Selectors with querySelectorAll
The DOM API exposes CSS selector matching through two methods: document.querySelector(selector) returns the first match, and document.querySelectorAll(selector) returns a static NodeList of all matches. Both are available in every modern browser and in Node.js environments with a DOM library.
// Select all external links and log their hrefs
const externalLinks = document.querySelectorAll('a[href^="http"]');
externalLinks.forEach(link => console.log(link.href));
// Find the first submit button in a form
const submitBtn = document.querySelector('form button[type="submit"]');
console.log(submitBtn?.textContent);
// Count table rows (excluding header)
const rows = document.querySelectorAll('table tbody tr');
console.log(`${rows.length} data rows`);
// Combine attribute and pseudo-class selectors
const checkedBoxes = document.querySelectorAll('input[type="checkbox"]:checked');
const values = [...checkedBoxes].map(el => el.value);
// Scope to a subtree — pass element as context to closest parent
const nav = document.querySelector('#main-nav');
const navLinks = nav?.querySelectorAll('a[href]') ?? [];In the browser DevTools console you can also use $$(selector) as a shorthand for document.querySelectorAll — it returns a real Array rather than a NodeList, so array methods like .map() and .filter() work directly without spreading. This shorthand is only available in the DevTools console, not in page scripts.
CSS Selectors vs XPath for Web Scraping
Both CSS selectors and XPath can query HTML, and many scrapers support both. Choosing the right tool depends on the task:
| Capability | CSS Selectors | XPath |
|---|---|---|
| Browser native API | querySelectorAll — built in | document.evaluate — built in but verbose |
| Syntax brevity | Concise for common patterns | Verbose but expressive |
| Select by text content | No (CSS4 :has() partial workaround) | Yes — [text()='Buy'] |
| Traverse upward (parent/ancestor) | No (CSS has no parent selector)* | Yes — ancestor:: axis |
| Attribute extraction | Via JS .getAttribute() | Direct — @href returns value |
| Readability for simple queries | Higher — familiar to CSS authors | Lower — unfamiliar syntax |
| Scraper library support | Excellent (Cheerio, Playwright, Puppeteer) | Good (lxml, Scrapy, Selenium) |
* CSS Level 4 adds :has() which allows limited upward selection (e.g., li:has(> a.active)), but it is not the same as a full ancestor axis.
Use CSS selectors when: you are working in a JavaScript environment, the selector library you use (Cheerio, Playwright) is CSS-first, or the queries are simple element/class/attribute matches.
Use XPath when: you need to match by text content ([text()=]), traverse upward to a parent or ancestor, or work in a Python/lxml pipeline where XPath is the natural choice. See the XPath Tester for side-by-side experimentation with XPath expressions.
For pattern-based string matching within selector predicates or extracted text, the Regex Cheatsheet is a useful companion reference.
Best Practices
- Prefer class selectors over complex combinators. A single class like
.card-titleis more robust and readable thansection > div:first-child > h3, which breaks on any DOM restructure. - Use data attributes for JavaScript hooks. Write
[data-action="submit"]rather than coupling your JS to style classes like.btn-primary. Styling and behaviour selectors should be independent. - Scope querySelectorAll to a subtree. Call
container.querySelectorAll(...)rather thandocument.querySelectorAll(...)to limit the search to the relevant DOM subtree — faster and less likely to match unrelated elements. - Avoid over-qualified selectors.
div.container ul li a.linkrepeats information the browser already knows and makes the selector harder to maintain.a.linkis usually sufficient. - Test incrementally. Build your selector one component at a time in the CSS Selector Tester to verify each step before adding the next combinator or pseudo-class.
Ready to put these patterns into practice? Open the CSS Selector Tester and paste in any HTML document to run every selector from this guide against your own markup in seconds — no browser DevTools required.