Skip to content

CSS Selector Cheat Sheet

Every everyday CSS selector on one page, from attributes and combinators to :has() and ::marker, each with a working example and its specificity. Paste your own selectors into the calculator to see which one wins and why.

67 selectors

Basics

Universal

*
*, *::before, *::after {
  box-sizing: border-box;
}

Every element, with no specificity at all. Pseudo-elements are not elements, which is why resets list ::before and ::after separately.

Basics

Type

p
p {
  max-width: 65ch;
}

Elements by tag name. Case does not matter for HTML tags, so P and p are the same.

Basics

Class

.card
.card {
  padding: 1.5rem;
}

Any element whose class attribute lists card as one of its space-separated words. Class names are case-sensitive: .Card will not match class="card".

Basics

ID

#pricing
#pricing {
  scroll-margin-top: 5rem;
}

The element with id="pricing". One ID outweighs any number of classes, so ID rules are hard to override later. Many teams keep IDs for anchors and scripts.

Basics

Compound

button.primary
button.primary {
  background: #0369a1;
}

No space between the parts: one element has to match all of them. If there is a type selector, it goes first, so write button.primary, not .primarybutton.

Basics

Selector list

h1, h2, h3
h1, h2, h3 {
  line-height: 1.2;
}

A comma separates alternatives that share the declarations, and each keeps its own specificity. If one entry is invalid, the browser drops the whole rule; inside :is() it is ignored instead.

Attributes

Has the attribute

[disabled]
button[disabled] {
  cursor: not-allowed;
}

The attribute is present, whatever its value, even an empty one.

Attributes

Exact value

[type="email"]
input[type="email"] {
  min-width: 18rem;
}

The whole value must match. Quotes are optional for a plain word, but values with spaces, dots or a leading digit need them.

Attributes

One word in a list

[data-tags~="sale"]
[data-tags~="sale"] .price {
  color: #b91c1c;
}

The value is a space-separated list that contains this exact word, the way a class selector reads the class attribute. data-tags="new sale" matches; "wholesale" does not.

Attributes

Value or value-

[lang|="en"]
[lang|="en"] q {
  quotes: "“" "”";
}

Exactly en, or en followed by a hyphen (en-GB, en-US). It was built for language codes, but :lang(en) does the job better because it also sees inherited lang.

Attributes

Starts with

[href^="mailto:"]
a[href^="mailto:"]::before {
  content: "✉ ";
}

The value begins with the string. Good for link types: mailto:, tel:, or https:// for external links.

Attributes

Ends with

[href$=".pdf"]
a[href$=".pdf"]::after {
  content: " (PDF)";
}

The value ends with the string. A link like report.pdf?v=2 will not match, because the query string comes last.

Attributes

Contains

[class*="col-"]
[class*="col-"] {
  min-width: 0;
}

The string appears anywhere in the value, so this also matches class="no-col-gap". When you control the markup, a dedicated class is more precise.

Attributes

Ignore case

[href$=".pdf" i]
a[href$=".pdf" i]::after {
  content: " (PDF)";
}

The i flag compares the value case-insensitively, so .PDF and .Pdf match too. Every current browser supports i. The opposite flag, s, has very little support.

Combinators

Descendant (space)

.card p
.card p {
  margin-block: 0.5em;
}

A p anywhere inside .card, at any depth, including inside a nested card.

Combinators

Child (>)

.menu > li
.menu > li {
  display: inline-block;
}

Direct children only. List items in a submenu inside .menu are skipped.

Combinators

Next sibling (+)

h2 + p
h2 + p {
  margin-top: 0;
}

The p directly after an h2, with the same parent. Text between them does not count, but any element in between breaks the match.

Combinators

Later siblings (~)

h2 ~ p
h2 ~ p {
  color: #475569;
}

Every p after the h2 under the same parent, with anything in between. Like all combinators except :has(), it only looks forward, never back.

User action

Hover

:hover
@media (hover: hover) {
  .card:hover {
    transform: translateY(-2px);
  }
}

A pointer is over the element or one of its descendants. On touch screens it fires on tap and can stick, so wrap hover-only effects in @media (hover: hover) and never hide anything essential behind them.

User action

Pressed

:active
.button:active {
  transform: translateY(1px);
}

While the element is being pressed. :link, :visited, :hover and :active all count as one pseudo-class, so for links put them in that order (LVHA) or a later one will override an earlier one.

User action

Focus

:focus
input:focus {
  border-color: #0284c7;
}

The element has focus by any route: keyboard, mouse or script.

User action

Visible focus

:focus-visible
.button:focus-visible {
  outline: 3px solid #0284c7;
  outline-offset: 2px;
}

Focus that the browser judges should be shown: keyboard focus, and text fields however they were focused. Use it for focus rings instead of removing outline on :focus.

User action

Focus inside

:focus-within
.field:focus-within {
  box-shadow: 0 0 0 3px #bae6fd;
}

The element has focus or contains the focused element. It highlights a whole field group while someone types in its input.

Links

Unvisited link

:link
a:link {
  color: #0369a1;
}

An a or area element with an href that has not been visited. A plain <a> with no href matches neither :link nor :visited.

Links

Visited link

:visited
a:visited {
  color: #6d28d9;
}

Links in the browsing history. To protect privacy, browsers only apply colour properties here, and scripts reading the computed style see the unvisited colours.

Links

Any link

:any-link
:any-link {
  text-underline-offset: 0.2em;
}

Every a or area with an href, visited or not. It is shorter than :link, :visited, and counts as one pseudo-class.

Links

URL fragment target

:target
section:target {
  background: #fef9c3;
}

The element whose id matches the #fragment in the address bar, for example #pricing. It changes when someone follows an in-page link.

Forms

Checked

:checked
input:checked + label {
  font-weight: 600;
}

Ticked checkboxes, the selected radio button and selected <option>s. The + label pattern only works when the label comes right after the input.

Forms

Disabled

:disabled
button:disabled {
  opacity: 0.6;
}

Controls with the disabled attribute, including controls inside a disabled <fieldset>. aria-disabled="true" does not count, so target that attribute separately.

Forms

Required

:required
label:has(+ :required)::after {
  content: " *";
}

Inputs, selects and textareas with the required attribute. :optional matches the rest.

Forms

Invalid

:invalid
form:invalid button[type="submit"] {
  opacity: 0.6;
}

The value breaks a constraint: required, type, pattern, min, max or length. An empty required field is :invalid from the moment the page loads, so error styles here show before anyone types.

Forms

Invalid after input

:user-invalid
input:user-invalid {
  border-color: #dc2626;
}

Like :invalid, but only once the person has changed the field and moved on, or tried to submit. It is the one to use for red error borders.

Forms

Placeholder showing

:placeholder-shown
input:not(:placeholder-shown) + label {
  transform: translateY(-1.2em) scale(0.85);
}

The field is empty and showing its placeholder, which is the trick behind floating labels. The input needs a placeholder attribute; a single space is enough.

Forms

Indeterminate

:indeterminate
input[type="checkbox"]:indeterminate {
  accent-color: #94a3b8;
}

A checkbox set to indeterminate by script (the "some selected" state), a radio group with nothing chosen, or a <progress> without a value.

Position

Root element

:root
:root {
  --brand: #0369a1;
}

The top element, which is <html> in a web page. It has class-level specificity (0, 1, 0), whereas html has (0, 0, 1). That is one reason custom properties live here.

Position

First child

li:first-child
li:first-child {
  border-top: 0;
}

An li that is the first element among its siblings. If another element comes first, no li matches. That case calls for :first-of-type.

Position

Last child

li:last-child
li:last-child {
  margin-bottom: 0;
}

The last element among its siblings. Text and comments do not count, only elements.

Position

Only child

:only-child
.tag:only-child {
  margin: 0;
}

The element has no element siblings.

Position

Nth child

tr:nth-child(even)
tr:nth-child(even) {
  background: #f8fafc;
}

An+B counts from 1: 2n+1 or odd gives 1, 3, 5; 3n gives 3, 6, 9; -n+3 gives the first three. It counts every element sibling, whatever its type.

Position

Nth child of a selector

tr:nth-child(even of :not([hidden]))
tr:nth-child(even of :not([hidden])) {
  background: #f8fafc;
}

Counts only the siblings that match the selector after of, so zebra stripes stay even when rows are filtered out. The selector after of adds to the specificity.

Position

Nth from the end

li:nth-last-child(-n+3)
li:nth-last-child(-n+3) {
  font-weight: 600;
}

The same formula counted from the last sibling. -n+3 picks the last three.

Position

First of its type

p:first-of-type
article p:first-of-type {
  font-size: 1.125rem;
}

The first p among its siblings, even if an h2 or img comes before it. Note that "type" means tag name; .intro:first-of-type does not mean the first .intro.

Position

Nth of its type

img:nth-of-type(3n)
.gallery img:nth-of-type(3n) {
  margin-right: 0;
}

Counts only siblings with the same tag name. :last-of-type, :nth-last-of-type() and :only-of-type work the same way.

Position

Empty

:empty
.alert:empty {
  display: none;
}

No children at all, including text. A single space or line break counts as content, so <div> </div> is not :empty. Comments are ignored.

Logic

Not

.tab:not(.active)
.tab:not(.active) {
  opacity: 0.7;
}

Elements that do not match. It accepts a list, as in :not(.a, .b), and takes the specificity of its most specific argument, so :not(#x) adds an ID.

Logic

Is (any of)

:is(h1, h2, h3) a
:is(h1, h2, h3) a {
  text-decoration: none;
}

Matches if any argument matches, which saves writing out every combination. An invalid argument is skipped instead of breaking the rule. Its specificity is that of the most specific argument.

Logic

Where (zero weight)

:where(ul, ol)
:where(ul, ol)[class] {
  list-style: none;
  padding: 0;
}

Works like :is() but adds zero specificity, whatever is inside. Use it for defaults and resets that any later rule should be able to override.

Logic

Has (parent selector)

.card:has(> img)
.card:has(> img) {
  padding-top: 0;
}

An element containing a match: here, a card with an img as a direct child. h2:has(+ p) checks the next sibling instead. Supported in Chrome 105, Safari 15.4 and Firefox 121 and later.

Language & state

Language

:lang(de)
:lang(de) {
  hyphens: auto;
}

Matches by language, including lang inherited from an ancestor and longer codes such as de-AT. [lang="de"] only checks the element’s own attribute.

Language & state

Direction

:dir(rtl)
.icon-arrow:dir(rtl) {
  transform: scaleX(-1);
}

Matches by text direction, whether inherited or detected by dir="auto". [dir="rtl"] only sees the attribute itself.

Language & state

Defined

:defined
my-widget:not(:defined) {
  visibility: hidden;
}

Built-in elements, plus custom elements once their definition has loaded. :not(:defined) hides a web component until its script runs.

Language & state

Open popover

:popover-open
[popover]:popover-open {
  opacity: 1;
}

An element with the popover attribute while it is showing.

Language & state

Modal

:modal
dialog:modal {
  max-width: 32rem;
}

A dialog opened with showModal() rather than show(), or an element in fullscreen. While a modal dialog is open, the rest of the page is inert.

Pseudo-elements

Before

::before
.badge::before {
  content: "";
  width: 0.5rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: currentColor;
}

A generated first child. It only appears with a content property, even an empty one, and not on img or most inputs, which have no inside.

Pseudo-elements

After

::after
.external::after {
  content: " ↗" / "";
}

A generated last child. The text after the slash is the alternative text for screen readers, and an empty string keeps decorative symbols out of the spoken name.

Pseudo-elements

First line

::first-line
.lead::first-line {
  font-variant: small-caps;
}

The first rendered line of a block, wherever it wraps at the current width. Only font, colour, background and text properties apply.

Pseudo-elements

First letter

::first-letter
.story > p:first-of-type::first-letter {
  float: left;
  font-size: 3.5em;
  line-height: 1;
}

The first letter of a block, together with any punctuation before it. This is the usual way to make a drop cap.

Pseudo-elements

Placeholder text

::placeholder
input::placeholder {
  color: #64748b;
}

The placeholder text in an empty field. Pale placeholder text often fails contrast checks, and it disappears as soon as someone types, so it should never be the only label.

Pseudo-elements

Selected text

::selection
::selection {
  background: #fde68a;
  color: #0f172a;
}

Text the person has highlighted. Only a few properties apply, mainly color, background-color, text-decoration and text-shadow.

Pseudo-elements

List marker

::marker
li::marker {
  color: #0369a1;
  content: "→ ";
}

The bullet or number of a list item, or the triangle of a <summary>. It takes colour, font properties, content and a few others; layout properties are ignored.

Pseudo-elements

File input button

::file-selector-button
input[type="file"]::file-selector-button {
  border-radius: 0.5rem;
}

The "Choose file" button inside <input type="file">.

Pseudo-elements

Backdrop

::backdrop
dialog::backdrop {
  background: rgb(15 23 42 / 0.6);
}

The layer behind a modal dialog, a popover or a fullscreen element, covering the rest of the page.

Pseudo-elements

Shadow part

::part(label)
my-button::part(label) {
  font-weight: 600;
}

An element inside a web component’s shadow root that the component exposes with part="label". Other elements inside the shadow root cannot be selected from outside.

Pseudo-elements

Slotted content

::slotted(img)
/* inside the component’s shadow root */
::slotted(img) {
  max-width: 100%;
}

Used in a shadow root’s stylesheet. It styles elements from the page that are placed in a <slot>, but only the top-level ones, not their descendants.

Nesting & components

Nesting selector

&from the parent
.card {
  &:hover { border-color: #0369a1; }
  & + & { margin-top: 1rem; }
  .dark & { background: #0f172a; }
}

In nested CSS, & stands for the parent rule’s selector. It counts like :is(parent), so if the parent is #a, .b, every match gets ID specificity.

Nesting & components

Shadow host

:host
/* inside the shadow root */
:host {
  display: block;
}
:host(.compact) {
  padding: 0.25rem;
}

Inside a shadow root, the custom element itself. :host(.compact) matches when the element has that class on the page.

Nesting & components

Scope root

:scope
@scope (.card) {
  :scope { border: 1px solid #cbd5e1; }
  img { border-radius: 0.5rem; }
}

The root of an @scope block. In JavaScript, el.querySelectorAll(":scope > li") means the direct children of el. In a plain stylesheet it means the same as :root.

How a browser matches a selector

Browsers read a selector from right to left. For .card > h2 they take each h2 and then check whether its parent has the class card. The part on the far right decides which elements are even considered; everything to its left is a filter on those elements’ ancestors or siblings.

<article class="card">
  <h2>Account</h2>
  <div class="details">
    <h2>Billing</h2>
  </div>
</article>

.card h2 matches both headings, because a space allows any depth. .card > h2 matches only Account: Billing’s parent is .details, not the card. Both selectors have specificity 0, 1, 1. A combinator changes which elements match but never adds weight, so choose it for the relationship you need and settle conflicts some other way.

Siblings work the same way. h2 + p is only the paragraph directly after the heading, and h2 ~ p is every later paragraph under the same parent. Neither can look backwards or up to a parent; that is what :has() is for.

Counting specificity

Specificity is three separate counts, written A, B, C. When two rules set the same property on the same element, compare A first, then B, then C. The first difference decides, and a higher count in a later column never makes up for a lower one in an earlier column. Eleven classes (0, 11, 0) still lose to one ID (1, 0, 0).

What adds to each column of specificity
ColumnCountsExamples
AID selectors#pricing
BClasses, attribute selectors, pseudo-classes.card [type="email"] :hover
CType selectors and pseudo-elementsbutton ::before :after
NothingUniversal selector, combinators, :where() and everything inside it* > + ~ :where(#a)
Argument:is(), :not(), :has(): their most specific argument:not(#x) = 1, 0, 0
ParentNesting &: the parent selector, counted like :is()& in #a, .b {…} = 1, 0, 0

Two edge cases trip people up. :root is a pseudo-class, so it weighs 0, 1, 0 while html weighs 0, 0, 1, even though both select the same element. And the old one-colon forms :before, :after, :first-line and :first-letter still count as pseudo-elements in column C, not as pseudo-classes.

Worked example: the link that stays grey

A sidebar menu marks the current page with a class, but the active link refuses to turn blue:

<nav id="sidebar">
  <ul class="nav">
    <li><a class="link is-active" href="/billing">Billing</a></li>
  </ul>
</nav>

#sidebar a { color: #64748b; }             /* 1, 0, 1 */
.nav .link.is-active { color: #0369a1; }   /* 0, 3, 0 */

Compare column A first: 1 against 0. The grey rule wins there and then, so the three classes in the blue rule never come into it, and moving the blue rule lower in the file changes nothing. Paste both lines into the calculator above (or press Load Example in Specificity mode) to see the breakdown.

Three ways to fix it, from best to worst:

  1. Lower the base rule. :where(#sidebar) a keeps the same matching but weighs 0, 0, 1, so the state rule at 0, 3, 0 wins.
  2. Put base styles in a cascade layer. Wrap the sidebar rule in @layer base { … }. Unlayered rules beat layered ones before specificity is checked at all, so the ID no longer matters.
  3. Raise the state rule to #sidebar .link.is-active (1, 2, 0). It works, but every later override now has to start with an ID too.

Adding !important also “works”, but it moves the declaration into a different part of the cascade, and the only thing that beats it later is another !important.

:is(), :where(), :not(), :has() and “of”

Specificity and error handling of functional pseudo-classes
Pseudo-classSpecificityAn invalid argument…
:is(.a, #b)Most specific argument: 1, 0, 0is skipped; the rest still match
:where(.a, #b)Always 0, 0, 0is skipped
:not(.a, #b)Most specific argument: 1, 0, 0drops the whole rule
:has(> img, .b)Most specific argument: 0, 1, 0drops the whole rule
:nth-child(2n of .b)Pseudo-class + argument: 0, 2, 0drops the whole rule

The “most specific argument” rule applies even when the element matched through a lighter argument. :is(.card, #featured) > h2 weighs 1, 0, 1 for every h2 it matches, including those inside a plain .card. If you want grouping without that side effect, use :where() and the same selector weighs 0, 0, 1.

Skipping invalid arguments makes :is() and :where() handy for vendor-specific pseudo-classes. A plain list such as .btn:focus-visible, .btn:-moz-focusring is dropped entirely by browsers that do not know the Firefox-only part, while .btn:is(:focus-visible, :-moz-focusring) keeps working everywhere. Pseudo-elements cannot go inside :is(), so for those, write separate rules. :has() briefly behaved this way too, but browsers changed it in 2023, so an invalid argument now invalidates it.

li:nth-child(2n of .visible) counts only the visible siblings, so stripes stay even when rows are hidden. li.visible:nth-child(2n) looks similar but counts every sibling and then checks the class, so a hidden row throws the pattern out.

Where specificity sits in the cascade

Specificity only settles a conflict once everything above it in this list is equal. The browser works down the list and stops at the first step that separates the two declarations:

  1. Origin and importance. Your !important declarations beat your normal ones, and normal author styles beat the browser’s defaults. !important is not a fourth column of specificity; it comes first.
  2. Context. With shadow DOM, normal page styles beat a component’s internal styles, and the order flips for !important.
  3. Style attribute. style="…" beats any selector. It has no specificity of its own; it simply wins at this step.
  4. Cascade layers. Later layers beat earlier ones, and unlayered styles beat all layers. For !important the layer order is reversed.
  5. Specificity, as calculated on this page.
  6. Scope proximity. Inside @scope, the rule whose scope root is fewer steps up from the element wins.
  7. Order of appearance. The last declaration in the CSS wins.

The calculator here only does step 5. It reads selector text and does not see your HTML or the rest of your stylesheet. When a declaration loses, browser developer tools show it struck through, and the Computed panel shows the rule that won.

Selectors that look right but do not match

Common selector mistakes
SelectorWhy it missesFix
.card .titleSpace means “inside”, but the classes are on the same element.card.title
li:first-childAnother element, such as a heading or template, comes firstli:first-of-type
.item:first-of-type“Type” means tag name, not class.item:nth-child(1 of .item)
.alert:emptyA space or line break inside counts as contentPrint the element with nothing between the tags
[class^="btn"]class="large btn-x" starts with “large”Give it a real class, or use [class*="btn-"]
h2 + pAnother element sits between them, or they have different parentsh2 ~ p, or check the markup
a::beforeNo content propertyAdd content: ""
img::afterImages and most inputs have no inside to put it inWrap the image and style the wrapper
input:invalidEmpty required fields are red before anyone typesinput:user-invalid
a:visitedOnly colour properties apply, for privacyChange colours only
.btn:focus-visible, .btn:-moz-focusringBrowsers that do not know one entry drop the whole rule, including the part they do know.btn:is(:focus-visible, :-moz-focusring), or separate rules

The same selectors in JavaScript

querySelector(), querySelectorAll(), matches() and closest() all take the same selector syntax as CSS, with a few differences worth knowing:

list.querySelectorAll(':scope > li');   // direct children of list only
button.closest('.card');                 // nearest ancestor (or itself) with .card
field.matches(':user-invalid');          // current state, true or false
document.querySelector('p::before');     // always null: pseudo-elements are not in the DOM
document.querySelector('#' + CSS.escape('1st-step'));  // "#\31 st-step"
  • An invalid selector throws a SyntaxError instead of being ignored, so wrap user-supplied selectors in try/catch.
  • IDs and classes that start with a digit, such as id="1st-step", are valid HTML but not valid CSS names. CSS.escape() turns them into the escaped form, and the calculator above accepts escaped names too.
  • querySelectorAll() returns a snapshot. Elements added later are not in it, unlike the live collection from getElementsByClassName().
  • To read a pseudo-element’s styles, use getComputedStyle(element, '::before').

Newer selectors and browser support

Everything else on this page has worked in every browser for years. These are the selectors where the version still matters, with the first version that supports each one fully. Edge follows Chrome.

First browser versions with full support for newer selectors
SelectorChromeFirefoxSafari
:not() with a list88849
:is() and :where()887814
:focus-visible868515.4
:has()10512115.4
:modal10510315.6
:nth-child(… of …)1111139
:popover-open11412517
:user-invalid / :user-valid1198816.5
:dir()1204916.4
Nesting with & (current syntax)12011717.2

To use one of these with a fallback, test for it with @supports selector(:has(a)). Browsers that do not understand the selector skip the block.

Frequently asked questions

What is the difference between .a .b and .a.b?

.a .b (with a space) is an element with class b somewhere inside an element with class a. .a.b (no space) is one element that has both classes.

Is there a parent selector in CSS?

Yes, :has(). .card:has(img) selects a card that contains an image. It works in Chrome 105, Safari 15.4, Firefox 121 and later.

Why do eleven classes not beat one ID?

Specificity columns do not carry over like digits in a number. The ID column is compared first, and 1 beats 0 no matter what the class column holds.

Does !important have a specificity?

No. Importance is checked before specificity, so an !important declaration beats a normal one however specific the normal one is. Specificity only decides between two !important declarations.

What specificity does an inline style have?

None, strictly. A style attribute wins at its own step of the cascade, before specificity is compared, so it beats every selector unless the selector rule uses !important.

How do I lower the specificity of a selector without changing what it matches?

Wrap the heavy part in :where(), which counts as zero. #nav a becomes :where(#nav) a at 0, 0, 1. Cascade layers are the other option when a whole set of rules should give way.

Sources