Type something to search...
CSS Selectors Cheat Sheet: From Basic to Advanced

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.

SelectorExampleMatches
Universal*Every element
TypepEvery p element
Class.cardElements with class="card"
ID#mainThe element with id="main"
Compounda.buttona 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.

CombinatorExampleMatches
Descendantnav aAny a inside a nav, at any depth
Childul > lili elements that are direct children of a ul
Next siblingh2 + pThe p immediately after an h2
Subsequent siblingh2 ~ pEvery 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.

SelectorMatches 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.

SelectorMatches
:first-childFirst child of its parent
:last-childLast child of its parent
:only-childAn 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-typeFirst element of its type among siblings
:nth-of-type(2)Second element of its type
:emptyElements with no children or text
:rootThe 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-elementWhat it targets
::beforeGenerated content before the element's content
::afterGenerated content after the element's content
::first-letterThe first letter of a block
::first-lineThe first rendered line of a block
::placeholderPlaceholder text in inputs
::selectionText the user has highlighted
::markerList item bullets or numbers
::backdropThe layer behind a modal dialog or fullscreen element
::file-selector-buttonThe 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.

SelectorSpecificity
p0, 0, 1
.card0, 1, 0
a:hover0, 1, 1
.nav .link0, 2, 0
#main1, 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.card or ul.menu li.menu-item a adds specificity with no benefit. Use .card and .menu-link.
  • Deep descendant chains. .header .nav ul li a span breaks 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 content on pseudo-elements. No content, no ::before.
  • Putting a new pseudo-class in a plain list. .a, .b:some-new-thing drops the whole rule in browsers that don't know the new selector. Use :is() or a separate rule.
  • Using :invalid for 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.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading