Geonode logo
Geonode Team

Geonode Team

Updated: September 2, 2026

Published: 2026-09-02

CSS Selectors Cheat Sheet: Guide with Examples

CSS selectors are the shortest way to say which elements you mean. They are used for styling, for querying in JavaScript, and for extraction in every serious scraping library. This is a reference organised for practical use: the syntax tables first, then the pseudo-classes that actually earn their place, then specificity, then the patterns that come up repeatedly. It also covers what CSS still cannot do, which is a shorter list than it used to be.

A note on who wrote this and why it matters little here: we are Geonode and we sell proxies to people doing extraction work. Selector choice has no bearing on anything we sell, and the only cross-over worth mentioning is diagnostic — a selector that matches nothing and a request that was blocked look identical from the outside, and they are completely unrelated problems. If the page arrived and your selector found nothing, this reference is the right place and nothing about how the page was fetched needs changing.

Basic Selectors

SelectorMatches
*Every element
divEvery div element
.cardElements with class card
#mainThe element with id main
.card.featuredElements with both classes
div.carddiv elements with class card
div, pdiv elements and p elements

Two of these are worth dwelling on.

.card.featured with no space means both classes on one element. .card .featured with a space means an element with class featured inside an element with class card. A single character changes the meaning entirely, and it is the most common typo in this whole area.

div, p is a selector list, not a relationship. It matches everything in either group, and each part is evaluated independently — so div, p.note matches all divs plus paragraphs with class note, not divs with class note.

Combinators

These express relationships between elements.

SelectorMatches
div pp anywhere inside a div (descendant)
div > pp that is a direct child of a div
h2 + pThe p immediately after an h2 (adjacent sibling)
h2 ~ pEvery p after an h2 with the same parent (general sibling)

The descendant combinator is a space, which makes it easy to introduce accidentally and easy to miss when reading.

> is more precise and usually more robust. div > p will not match a paragraph that someone wraps in a <section> next month — which sounds like a disadvantage and is often the opposite, because a selector that silently starts matching more elements than intended is worse than one that stops matching and tells you.

+ and ~ are the sibling combinators and they only look forward. There is no "previous sibling" combinator in CSS; :has() provides the effect indirectly, covered below.

Attribute Selectors

Frequently the most stable option available, since attributes are usually chosen deliberately.

SelectorMatches
[href]Elements with an href attribute
[href="/about"]Exact value
[href^="/docs"]Value starts with
[href$=".pdf"]Value ends with
[href*="download"]Value contains
[class~="card"]Value is a space-separated list containing the word card
[lang|="en"]Value is en or begins en-
[data-state="open" i]Case-insensitive match

Two are underused.

[class~="card"] is exactly equivalent to .card — the ~= operator does whitespace-separated word matching, which is what the class attribute needs. Knowing it exists is useful mainly for understanding what .card really does, and for building selectors programmatically where you have an attribute name in a variable.

The i flag makes a match case-insensitive, which is genuinely handy for attribute values that vary in casing across a site.

For extraction specifically, data-* attributes deserve a strong preference over classes. A [data-testid="product-card"] was chosen by a developer to be a stable hook; a class name may be regenerated by a build tool on every deploy.

Pseudo-Classes: Structural

SelectorMatches
:first-childAn element that is the first child of its parent
:last-childThe last child
:only-childAn element with no siblings
:nth-child(3)The third child
:nth-child(2n)Even-numbered children
:nth-child(2n+1)Odd-numbered children
:nth-child(-n+3)The first three
:nth-last-child(2)The second from the end
:first-of-typeThe first element of its type among siblings
:nth-of-type(2)The second of its type
:emptyAn element with no children, including text
:rootThe document root, usually html

The -of-type variants matter more than people realise. p:first-child matches a paragraph only if it is the first child of its parent — so if a heading comes first, it matches nothing. p:first-of-type matches the first paragraph regardless of what precedes it. For extraction, the second is almost always what you meant.

:nth-child(-n+3) for "the first three" is the idiom worth memorising from the An+B syntax, along with :nth-child(n+4) for "the fourth onwards". Combining them gives a range: :nth-child(n+2):nth-child(-n+5) selects children two through five.

Pseudo-Classes: Logical

The group that has changed most in recent years.

SelectorMatches
:not(.hidden)Elements without class hidden
:is(h1, h2, h3)Any of the listed selectors
:where(h1, h2, h3)Same as :is() but with zero specificity
:has(> img)Elements containing a matching descendant or sibling

:is() and :where() shorten repetitive lists. :is(article, section) > h2 replaces article > h2, section > h2, and the saving compounds quickly with longer lists. The only difference between them is specificity: :is() takes the specificity of its most specific argument, :where() always contributes zero.

:not() accepts a selector list in modern browsers, so :not(.a, .b) works and means "neither". Older single-argument behaviour required chaining :not(.a):not(.b).

:has() is the significant one. The MDN documentation describes it as representing "an element if any of the relative selectors that are passed as an argument match at least one element when anchored against this element", providing "a way of selecting a parent element or a previous sibling element with respect to a reference element". Its status is "Baseline widely available", supported across browsers since December 2023.

div.card:has(span.sold-out)     /* a card containing a sold-out marker */
h1:has(+ p)                     /* an h1 immediately followed by a p */
li:has(~ li.active)             /* an li with a later active sibling */
label:has(input:checked)        /* a label wrapping a checked input */

Three documented limits: it "cannot be nested" inside another :has(), pseudo-elements are not valid inside it or as anchors for it, and its specificity follows the same rule as :is() and :not() — the most specific argument.

Pseudo-Classes: State and Form

SelectorMatches
:hover, :focus, :activeInteraction states
:focus-visibleFocus that should show a visible ring
:focus-withinAn element containing a focused descendant
:checkedChecked checkbox, radio or selected option
:disabled, :enabledForm control state
:required, :optionalForm validation attributes
:valid, :invalidConstraint validation state
:placeholder-shownAn input showing its placeholder
:targetThe element matching the URL fragment
:visited, :linkLink states

Most of these are styling concerns rather than extraction concerns, with two exceptions. :checked is genuinely useful for reading form state in test automation, and :disabled tells you whether a control is interactive — which frequently explains why a click did nothing.

Note that :hover and :focus cannot be usefully queried with querySelectorAll in a scraping context, since neither state exists in a document you have merely parsed.

Specificity, Briefly

Relevant when selectors compete for styling, and irrelevant for querying — worth knowing because the confusion between the two contexts is common.

Specificity is counted as three numbers:

ComponentContributed by
Ids#main
Classes, attributes, pseudo-classes.card, [href], :hover
Elements, pseudo-elementsdiv, ::before

Compared left to right: any number of classes never outweighs a single id. Inline styles beat all of it, and !important beats that, which is why both are a last resort rather than a tool.

Two modern notes. :where() contributes zero specificity, which makes it the right choice for library defaults that consumers should be able to override without a fight. And :is(), :not() and :has() all take the specificity of their most specific argument, so :is(#main, div) is as specific as #main.

For querySelectorAll and scraping, specificity does not apply at all. The selector either matches an element or it does not; nothing is competing.

Patterns for Extraction

The selectors that do real work in a scraper.

Product cards in a grid:

div.product-grid > article.product-card

Links to PDFs:

a[href$=".pdf"]

External links:

a[href^="http"]:not([href*="example.com"])

A stable test hook rather than a class:

[data-testid="price"]

A container that has something in it:

tr:has(td.error)
article:has(img)
form:has(input:invalid)

The first paragraph of an article, regardless of what precedes it:

article p:first-of-type

Skip a header row:

tbody tr:not(:first-child)

Take rows two through eleven:

tbody tr:nth-child(n+2):nth-child(-n+11)

Elements with a specific class token, built programmatically:

[class~="card"]

Empty cells you want to exclude:

td:not(:empty)

Writing Selectors That Do Not Break

A reference tells you what is available. Choosing among the options is where durability comes from, and the difference between a selector that survives a redesign and one that breaks next week is entirely about what it depends on.

Rank your anchors by how deliberately they were chosen. A data-testid exists because a developer put it there for automation, so it is the most stable thing on the page. An id is next — usually intentional, occasionally generated. A semantic element or ARIA role is stable because it carries meaning. A hand-written class name is moderately stable. A generated class name such as css-1x9dj2k changes on every build and is worth nothing. Positional selectors are the least stable of all, because they encode the page's exact shape at one moment.

Be as specific as the meaning requires and no more. body > div > div > div.content > p is precise and brittle: any wrapper added anywhere in that chain breaks it. .content p expresses the actual requirement and survives restructuring. The instinct to add specificity for safety produces the opposite of safety.

Prefer selectors that fail loudly. A selector matching zero elements raises an obvious problem. A selector matching too many quietly returns the first one, which is plausible and possibly wrong. Given a choice, take the version that will break rather than the version that will drift — div > p over div p when the relationship really is direct.

Keep selectors in one place. Forty selector strings scattered through a codebase are forty independent liabilities. Collected in a single module with descriptive names, they become a map of what you depend on, and updating after a site change is an hour's work rather than a day's archaeology.

Assert the shape of what you extract. This is the habit that catches silent drift. If a price should match a currency pattern, check it. If a list has never had fewer than twenty items, treat fewer than twenty as an error. Without this, a selector that starts matching the wrong element produces well-formed wrong data indefinitely, and nothing raises an exception at any point.

Save the HTML you parsed. When a selector breaks, comparing the old markup against the new shows you exactly what changed in seconds. Re-fetching to investigate is slower, costs bandwidth, and may hand you a different page than the one that actually failed.

What CSS Still Cannot Do

A short list, and each item is a genuine reason to reach for XPath.

Match on text content. There is no standard :contains(). It was proposed and dropped, and no browser implements it. This is the largest gap and the reason the "find a value by its label" pattern is XPath territory — //dt[normalize-space()='Price']/following-sibling::dd[1] has no CSS equivalent.

Navigate to an arbitrary ancestor. :has() gives conditional parent selection — "the div that contains this span" — but there is no general ancestor axis. Walking up several levels to a specific container requires XPath.

String functions. No trimming, no substring, no case folding, no concatenation. Whatever you match, you process afterwards in your host language.

Query XML with namespaces. CSS has no namespace support to speak of. For sitemaps, RSS or SOAP, use XPath.

Select previous siblings directly. :has() provides the effect — h2:has(+ p) selects the heading — but there is no - combinator to mirror +.

That is the complete list for practical purposes. Everything else people reach for XPath to do, CSS now does, and usually more legibly.

Environment Differences Worth Checking

The most common selector surprise is a feature existing somewhere other than where you are running it.

Browsers support everything listed here, including :has(), :is(), :where() and multi-argument :not().

Server-side parsers vary considerably. Python's cssselect, used by lxml and Scrapy, translates CSS into XPath — which works well for the classic selectors and less well for newer ones. :has() support in particular is not universal, and a selector that works in a browser console may raise an error or silently return nothing in a parser.

BeautifulSoup uses soupsieve for its select() method, which has good but not identical coverage, and it has no XPath support at all.

Playwright and Selenium use the browser's engine, so support matches the browser — with Playwright additionally offering text and role locators that cover much of what CSS cannot express.

The practical habit: test selectors in the environment that will run them. A browser console is convenient and it is testing a different implementation against a post-JavaScript DOM, which is two ways it can mislead you.

People Also Ask

What is the difference between .card.featured and .card .featured?

The first, with no space, matches one element carrying both classes. The second, with a space, matches an element with class featured inside an element with class card. A single space changes the meaning completely and it is the most common CSS selector mistake.

How do I select a parent element in CSS?

With :has()div:has(> span.price) selects the div rather than the span. It has been widely available across browsers since December 2023. There is no general ancestor selector, so reaching a specific container several levels up still needs XPath.

Can CSS selectors match text?

No. There is no standard :contains() pseudo-class; it was proposed and never adopted. Matching by text content requires XPath, or a library-specific locator such as Playwright's getByText().

What is the difference between :nth-child and :nth-of-type?

:nth-child counts all children of the parent; :nth-of-type counts only elements of the same type. So p:first-child matches nothing if a heading comes first, while p:first-of-type matches the first paragraph regardless.

What does :is() do and how is it different from :where()?

Both take a selector list and match any of them, shortening repetitive selectors. The only difference is specificity: :is() adopts the specificity of its most specific argument, while :where() always contributes zero — which makes :where() ideal for defaults that should be easy to override.

How does CSS specificity work?

Ids outweigh classes, attributes and pseudo-classes, which outweigh element and pseudo-element selectors, compared component by component. Inline styles beat selectors and !important beats those. None of it applies when you are using selectors to query rather than to style.

Are CSS selectors better than XPath for scraping?

For class, id, attribute and structural selection, yes — shorter and clearer. For matching text content or walking up to an arbitrary ancestor, XPath is required. Most scraping libraries support both, and using each where it fits is normal.

Why does my selector work in the browser but not in my scraper?

Two usual causes. The browser DOM is post-JavaScript while your parser sees raw HTML, so the element may not exist in what you fetched. And server-side CSS implementations do not cover every modern selector — :has() in particular is inconsistently supported outside browsers.

Wrapping Up

The working core of CSS selectors is small: type, class and id for identity, the four combinators for relationships, attribute selectors for anything chosen deliberately, and a handful of pseudo-classes for position and state.

Three habits improve real selectors more than knowing additional syntax. Prefer > over a bare space where the relationship is genuinely direct, because a selector that stops matching is more useful than one that starts matching too much. Prefer data-* attributes over class names, because classes are regenerated by build tools and attributes are chosen by people. And prefer :nth-of-type over :nth-child when you mean "the second paragraph" rather than "the second child".

:has() is the addition worth adopting deliberately. Parent selection and sibling conditions in plain CSS remove a large share of what previously forced people into XPath, and it has been widely available for long enough to rely on in a browser. Outside browsers, check your parser first — that is where the remaining surprises live.

CSS Selectors Cheat Sheet: Syntax Pseudo-Classes and Real Patterns | Geonode