
CSS Selectors Cheat Sheet: From Basic to Advanced
Every CSS rule you write starts with a selector. It's the part that answers the question "which elements should these styles apply to?" Get it right and your styles land exactly where you want them. Get it wrong and you end up fighting specificity, adding extra classes everywhere, or reaching for JavaScript to do something CSS could handle on its own.
The selector toolbox has grown a lot. Beyond the classics like classes and descendants, modern browsers now support relational selectors like :has(), filtered :nth-child(), forgiving selector lists, and state-based pseudo-classes such as :user-invalid. This cheat sheet walks from the basics to the advanced end, with short examples you can copy straight into a project.
Bookmark it, skim the tables, and come back whenever you need a refresher.
Basic Selectors
These are the building blocks. Almost every stylesheet uses all of them.
| Selector | Example | Matches |
|---|---|---|
| Universal | * | Every element |
| Type | p | Every p element |
| Class | .card | Elements with class="card" |
| ID | #main | The element with id="main" |
| Compound | a.button | a elements that also have class button |
* {
box-sizing: border-box;
}
p {
line-height: 1.6;
}
.card {
padding: 1.5rem;
border-radius: 12px;
}
#main {
max-width: 72rem;
margin-inline: auto;
}
a.button {
display: inline-block;
text-decoration: none;
}
A quick rule of thumb: prefer classes. Type selectors are fine for base styles, but IDs carry very high specificity and can only be used once per page, which makes them awkward for reusable styles.
Selector Lists
Separate selectors with commas to apply the same declarations to several targets:
h1,
h2,
h3 {
font-family: "Inter", system-ui, sans-serif;
line-height: 1.2;
}
Be aware that a traditional selector list is unforgiving: if any one selector in the list is invalid, the whole rule is dropped. That matters when you mix in newer pseudo-classes. The :is() and :where() functions, covered below, accept a forgiving list instead.
Combinators
Combinators describe relationships between elements. There are four you'll use regularly.
| Combinator | Example | Matches |
|---|---|---|
| Descendant | nav a | Any a inside a nav, at any depth |
| Child | ul > li | li elements that are direct children of a ul |
| Next sibling | h2 + p | The p immediately after an h2 |
| Subsequent sibling | h2 ~ p | Every p after an h2 that shares the same parent |
<article class="post">
<h2>Section title</h2>
<p>Intro paragraph.</p>
<p>Second paragraph.</p>
<ul class="post-list">
<li>Item one</li>
<li>Item two</li>
</ul>
</article>
/* Links anywhere inside the post */
.post a {
color: #2563eb;
}
/* Only direct list items */
.post-list > li {
padding-block: 0.25rem;
}
/* Lead paragraph right after a heading */
.post h2 + p {
font-size: 1.125rem;
color: #334155;
}
/* All following paragraphs get a little space */
.post h2 ~ p {
margin-top: 0.75rem;
}
A popular spacing trick uses the next-sibling combinator with the universal selector. It's sometimes called the "lobotomized owl":
.stack > * + * {
margin-top: 1rem;
}
Every child except the first gets top margin, so you get even spacing without a stray margin at the start. These days display: flex with gap often does the same job, but the owl is still handy in flowing text content.
Attribute Selectors
Attribute selectors match elements based on their attributes and values. They are great for styling links, form inputs, and data attributes without extra classes.
| Selector | Matches when the attribute... |
|---|---|
[href] | exists |
[type="email"] | equals exactly email |
[class~="card"] | contains the word card in a space-separated list |
[lang|="en"] | is en or starts with en- |
[href^="https"] | starts with https |
[href$=".pdf"] | ends with .pdf |
[href*="example"] | contains example anywhere |
[data-state="open" i] | equals open, case-insensitively |
/* Mark external links */
a[href^="http"]:not([href*="tidewave"])::after {
content: " ↗";
}
/* Label download links */
a[href$=".pdf"]::before {
content: "PDF ";
font-weight: 700;
font-size: 0.75em;
}
/* Style based on component state */
.accordion[data-state="open"] .accordion-panel {
display: block;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: #6366f1;
}
Data attributes pair nicely with JavaScript. Toggle data-state in your script, and let CSS handle the look.
Pseudo-Classes
Pseudo-classes select elements based on state or position rather than anything written directly in the markup. They start with a single colon.
User Action and Focus
.button:hover {
background: #4f46e5;
}
.button:active {
transform: translateY(1px);
}
/* Visible focus ring only for keyboard-style focus */
.button:focus-visible {
outline: 3px solid #38bdf8;
outline-offset: 2px;
}
/* Highlight a form group when anything inside has focus */
.field:focus-within {
border-color: #38bdf8;
}
:focus-visible is the one to reach for when styling focus. It lets the browser decide when a focus indicator is helpful, which usually means keyboard navigation, so mouse users don't see rings after every click while keyboard users always do.
Form States
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input:checked + label {
font-weight: 600;
}
input:required {
border-left: 3px solid #fbbf24;
}
/* Only after the user has interacted with the field */
input:user-invalid {
border-color: #f472b6;
}
input:placeholder-shown {
font-style: italic;
}
:user-invalid is a big improvement over :invalid. Plain :invalid matches an empty required field on page load, before the user has typed anything, which makes forms look broken immediately. :user-invalid waits until the user has interacted with the field. It's supported in current versions of the major browsers.
Structural Pseudo-Classes
These select elements by their position among siblings.
| Selector | Matches |
|---|---|
:first-child | First child of its parent |
:last-child | Last child of its parent |
:only-child | An element with no siblings |
:nth-child(2) | The second child |
:nth-child(odd) | 1st, 3rd, 5th... |
:nth-child(3n) | Every third child |
:nth-child(n + 4) | The 4th child onward |
:nth-child(-n + 3) | The first three children |
:nth-last-child(2) | Second from the end |
:first-of-type | First element of its type among siblings |
:nth-of-type(2) | Second element of its type |
:empty | Elements with no children or text |
:root | The document root (html) |
/* Zebra-striped table rows */
.data-table tbody tr:nth-child(even) {
background: #f1f5f9;
}
/* Remove the border from the last item */
.menu-item:last-child {
border-bottom: none;
}
/* Hide empty notification badges */
.badge:empty {
display: none;
}
The An+B formula looks cryptic at first. Think of n as counting 0, 1, 2, 3 and so on. So 3n + 1 gives 1, 4, 7, 10. And -n + 3 gives 3, 2, 1, then stops, which is why it selects the first three.
Filtered :nth-child(An+B of S)
A newer and very useful addition is the of S syntax. It lets you count only the siblings that match a selector:
/* Stripe only the visible rows, ignoring hidden ones */
.data-table tr:nth-child(even of :not([hidden])) {
background: #f1f5f9;
}
/* The first two featured cards */
.card:nth-child(-n + 2 of .is-featured) {
outline: 2px solid #fbbf24;
}
Before this existed, filtering a table would break the zebra stripes because hidden rows were still counted. Now the count skips them. It's supported across current major browsers.
Pseudo-Elements
Pseudo-elements target part of an element, or generate content that isn't in the HTML. They use a double colon.
| Pseudo-element | What it targets |
|---|---|
::before | Generated content before the element's content |
::after | Generated content after the element's content |
::first-letter | The first letter of a block |
::first-line | The first rendered line of a block |
::placeholder | Placeholder text in inputs |
::selection | Text the user has highlighted |
::marker | List item bullets or numbers |
::backdrop | The layer behind a modal dialog or fullscreen element |
::file-selector-button | The button in input type="file" |
.required-label::after {
content: " *";
color: #f472b6;
}
.article p:first-of-type::first-letter {
float: left;
font-size: 3.5em;
line-height: 1;
margin-right: 0.1em;
}
::selection {
background: #fde68a;
color: #111827;
}
.checklist li::marker {
color: #34d399;
}
dialog::backdrop {
background: rgb(15 23 42 / 0.6);
}
Remember that ::before and ::after need a content property, even if it's an empty string, or they won't render at all.
Logical and Relational Pseudo-Classes
This is where modern CSS gets genuinely powerful.
:is() and :where()
Both take a selector list and match any element that matches one of the items. They make long, repetitive selectors much shorter:
/* Instead of: .post h1 a, .post h2 a, .post h3 a */
.post :is(h1, h2, h3) a {
color: inherit;
}
The difference between them is specificity. :is() takes the specificity of its most specific argument. :where() always has zero specificity, which makes it ideal for defaults that should be easy to override:
:where(.prose) :where(ul, ol) {
padding-left: 1.25rem;
}
Both also accept a forgiving selector list, so one invalid selector inside doesn't kill the whole rule.
:not()
:not() excludes elements. Modern browsers accept a full selector list inside it:
/* Every link that isn't a button and isn't in the nav */
a:not(.button, nav a) {
text-decoration: underline;
text-underline-offset: 3px;
}
:has(): The Relational Selector
:has() matches an element if at least one of the selectors inside it matches relative to that element. It's often called the "parent selector", but it's more flexible than that.
/* A card that contains an image gets a different layout */
.card:has(img) {
display: grid;
grid-template-columns: 160px 1fr;
gap: 1rem;
}
/* A form field whose input is invalid after interaction */
.field:has(input:user-invalid) .field-hint {
color: #f472b6;
}
/* A heading directly followed by a paragraph */
h2:has(+ p) {
margin-bottom: 0.25rem;
}
/* Lock page scroll while a modal dialog is open */
html:has(dialog[open]) {
overflow: hidden;
}
:has() is supported in current versions of Chrome, Edge, Safari, and Firefox. If you still need to support older browsers, guard it with @supports selector(:has(a)) and give them a sensible baseline.
Scoping Selectors
:scope and @scope
:scope refers to the scoping root. In a plain stylesheet it's the same as :root, but inside @scope it points at the scope's root element:
@scope (.card) to (.card-content) {
:scope {
border: 1px solid #e2e8f0;
}
img {
border-radius: 8px;
}
}
Here, img only matches images inside .card but not inside .card-content. @scope is supported in Chromium-based browsers and Safari, and it has been arriving in Firefox; check caniuse before relying on it for critical styles.
Nesting and &
Native CSS nesting uses & to refer to the parent selector:
.nav-link {
color: #334155;
&:hover {
color: #2563eb;
}
&.is-active {
font-weight: 700;
}
.nav-dark & {
color: #e2e8f0;
}
}
Nesting is supported in all current major browsers.
Specificity Quick Reference
When two rules target the same element, the browser compares specificity to decide which wins. It's calculated as three columns: IDs, classes/attributes/pseudo-classes, and types/pseudo-elements.
| Selector | Specificity |
|---|---|
p | 0, 0, 1 |
.card | 0, 1, 0 |
a:hover | 0, 1, 1 |
.nav .link | 0, 2, 0 |
#main | 1, 0, 0 |
:where(#main .card) | 0, 0, 0 |
:is(#main, .card) | 1, 0, 0 |
.card:not(.is-hidden) | 0, 2, 0 |
Compare left to right. One ID beats any number of classes. The universal selector * and combinators add nothing.
Common Mistakes
- Over-qualifying selectors.
div.cardorul.menu li.menu-item aadds specificity with no benefit. Use.cardand.menu-link. - Deep descendant chains.
.header .nav ul li a spanbreaks the moment markup changes. Keep selectors short and meaningful. - Styling with IDs. They're hard to override and can't be reused. Keep IDs for anchors and JavaScript hooks.
- Forgetting
contenton pseudo-elements. Nocontent, no::before. - Putting a new pseudo-class in a plain list.
.a, .b:some-new-thingdrops the whole rule in browsers that don't know the new selector. Use:is()or a separate rule. - Using
:invalidfor error styling. It fires before the user types. Prefer:user-invalid.
Conclusion
Selectors are the most fundamental part of CSS, and they've quietly become one of its most powerful features. The basics, classes, combinators and attributes, will cover most of your day-to-day work. Structural pseudo-classes and :nth-child(of S) take care of patterns that used to require extra markup. And the modern relational set, :is(), :where(), :not() and :has(), lets you express conditions that once needed JavaScript.
Keep your selectors short, lean on classes, reach for :where() when you want low-specificity defaults, and let :has() do the heavy lifting when styling depends on what's inside an element.


