A CSS selector cheat sheet earns its bookmark by being faster to scan than a search engine. This one is organized the way you'd actually reach for it mid-build: basic selectors first, then combinators, then the pseudo-classes and attribute selectors that cover almost every real-world case, with a live tester at the end for when you want to confirm a selector actually matches what you think it matches before shipping it.

Basic selectors
| Selector | Example | Matches |
|---|---|---|
| Type | p | Every p element |
| Class | .card | Every element with class card |
| ID | #header | The element with id header |
| Universal | * | Every element |
| Grouping | h1, h2, h3 | Any element matching any listed selector |
Grouping selectors with a comma applies the same rule to each without repeating it, useful for consistent heading styles or shared reset rules, but keep an eye on it as a codebase grows: a long comma-separated list is often a sign the rule belongs on a shared class instead.
Combinators
Combinators describe the relationship between two selectors, not just a single element in isolation.
| Combinator | Example | Matches |
|---|---|---|
| Descendant (space) | .nav a | Any a anywhere inside .nav, at any depth |
| Child | .nav > li | Only li elements that are direct children of .nav |
| Adjacent sibling | h2 + p | A p immediately following a h2, same parent |
| General sibling | h2 ~ p | Any p that comes after a h2 and shares the same parent |
The descendant combinator (a plain space) is the one people reach for by default, but it's also the least specific, it matches at any depth, which can grab elements you didn't intend once markup gets nested a few levels deep. The child combinator is worth defaulting to instead whenever you actually mean "direct child," since it fails loudly (nothing matches) instead of silently grabbing something two levels down.
Pseudo-classes
Pseudo-classes select elements based on a state or position rather than a static attribute.
| Pseudo-class | Matches |
|---|---|
:hover | While the pointer is over the element |
:focus | While the element has keyboard focus |
:focus-visible | Focus shown only when it's keyboard-triggered, not a mouse click |
:first-child / :last-child | An element that is the first or last child of its parent |
:nth-child(n) | The nth child; supports formulas like :nth-child(2n) for every other element |
:not(selector) | Any element that doesn't match the selector inside the parentheses |
:is(selector-list) | Matches any selector in the list; shorthand for a group without repeating the rest of the selector |
:where(selector-list) | Same matching as :is(), but contributes zero specificity |
:has(selector) | An element that contains something matching the inner selector, the "parent selector" CSS lacked for years |
:disabled / :checked | Form elements in a disabled or checked state |
:has() is worth calling out specifically: it's the selector that finally lets you style a parent based on what's inside it, a card with an image versus one without, a form with an invalid field, without JavaScript. It reached Baseline "newly available" in late 2023 and, as of 2026, has effectively 100% support across Chrome, Edge, Firefox, and Safari, so you can ship it in production with no fallback. The same is now true of the other selectors on this sheet that used to need a caveat: :is(), :where(), and native CSS nesting all crossed into Baseline in 2023 and are safe defaults today rather than progressive enhancements. When you're unsure whether a specific selector is production-safe, the honest check is its Baseline status, not "does it work in my browser."
:is() and :where() both exist to shorten repetitive selector lists, and the difference between them is specificity: :where() always counts as zero, which makes it the safer choice inside a reset or a design system where you don't want to fight specificity wars with the components that consume it later.
Pseudo-elements
Pseudo-elements target a specific part of an element rather than the whole thing, and use a double colon in modern syntax.
| Pseudo-element | Targets |
|---|---|
::before / ::after | Generated content inserted before or after an element's actual content |
::first-line | The first rendered line of a block of text, recalculated on resize |
::first-letter | The first letter of a block, commonly used for drop caps |
::placeholder | The placeholder text inside an input |
::selection | The portion of text currently highlighted by the user |
Attribute selectors
Attribute selectors match based on the presence or value of an HTML attribute, no class or ID required.
| Selector | Matches |
|---|---|
[disabled] | Any element with a disabled attribute, regardless of value |
[type="text"] | Elements where the attribute equals exactly this value |
[href^="https"] | Attribute value starts with this string |
[href$=".pdf"] | Attribute value ends with this string |
[class*="btn"] | Attribute value contains this string anywhere |
[lang|="en"] | Value equals this string, or starts with it followed by a hyphen (locale matching) |
[href^="https"] is a genuinely useful one in practice: pair it with ::after to automatically add an external-link icon to every outbound link on a page, without touching the markup for each one individually.
Combining selectors
Most real-world selectors aren't a single piece from the tables above, they're several combined into one chain, and reading a combined selector left to right is the fastest way to understand what it does. nav a.active:not(:last-child) reads as: any a with class active inside a nav, that isn't the last child of its parent. Building selectors this way, one clause at a time, is easier to reason about than trying to write the whole thing from memory in one pass, and it's easier to debug later, since you can delete clauses one at a time to isolate which part of the chain isn't matching what you expect.
Common mistakes worth avoiding
- Over-qualifying a selector.
div.cardworks, but so does.cardalone, and the extra type selector only adds fragility if the markup ever changes from adivto something else. - Reaching for
!importantinstead of fixing specificity. A style that "won't apply" is almost always a specificity or ordering problem, not a browser bug, and!importanttends to just relocate the problem to the next override down the line. - Using an ID selector for styling. IDs carry high specificity that's hard to override later, which is exactly the property you don't want in a shared stylesheet. Reserve IDs for JavaScript hooks and anchor links, and use classes for styling.
- Forgetting that
:nth-child()counts all children, not just matching ones.li:nth-child(2)inside a mixed list ofliand other elements counts position across every child, not just thelielements, a common source of "why did it skip an item" confusion.
Specificity, in one paragraph
When two rules target the same element, the more specific one wins, and specificity is calculated roughly as: inline styles beat IDs, IDs beat classes and attribute selectors and pseudo-classes, and those beat plain type selectors. :where() is the one exception, it matches like a normal selector but adds nothing to that count. When a style "isn't applying" and the selector looks right in a quick read, specificity is the first thing worth checking, before assuming the browser is wrong.
Handy real-world recipes
The tables above are the vocabulary; these are the sentences you'll actually write most often, worth keeping within reach:
| Goal | Selector |
|---|---|
| Style a card only when it contains an image | .card:has(img) |
| Flag a form field that's invalid and has been touched | input:user-invalid |
| Add spacing between stacked items but not before the first | .stack > * + * |
| Zebra-stripe every other table row | tr:nth-child(even) |
| Target external links only | a[href^="http"]:not([href*="yourdomain.com"]) |
| Style a label whose checkbox is checked | input:checked + label |
The .stack > * + * pattern (often called the "lobotomized owl") is the one worth memorizing: it applies a style to every child except the first, which is exactly how you add consistent vertical rhythm to a stack of elements without a trailing margin hanging off the last one. It reads as "any element that immediately follows a sibling," so the first child, having nothing before it, is skipped.
Confirm a selector before you ship it
Reading a selector and knowing exactly what it matches gets harder as markup gets more nested, and a selector that looks right can still grab an extra element you didn't intend, or miss the one you meant. Rather than guessing from the CSS alone, our free CSS selector tester lets you paste real HTML and a selector, and see every matched element highlighted live in a rendered preview, along with the specificity breakdown, in your browser, nothing you paste is sent anywhere.
This is also the exact mechanic behind how Shotline scopes a piece of feedback to one specific element on a live page: a click generates a precise selector under the hood, the same kind of selector this cheat sheet covers, so a comment or a bug report is tied to the actual DOM node, not a vague description of where it is on the page. If you're wiring up a coding agent to act on that kind of feedback directly, how to add MCP servers to Claude Code covers the setup, and Playwright MCP with Claude Code covers the closely related case of giving an agent direct browser control.
Where Shotline fits
Every comment left through Shotline carries the CSS selector of the exact element it's pinned to, generated automatically the moment someone clicks, so whoever fixes it, a person or a coding agent working the queue, starts from the real DOM node instead of reconstructing it from a screenshot. If you're building the kind of agent-driven workflow this cheat sheet supports, what is vibe coding covers where that workflow tends to break down without a way to see the rendered page.
Shotline is free to try for 14 days, no card required, then from $19/mo (billed annually; $25 month-to-month). See it on the Shotline homepage or start a free trial.




