Skip to content

Selectors & the Cascade

A selector is a pattern that matches elements. The cascade is the algorithm that decides which of the matching declarations actually applies. Together they explain essentially every “why isn’t my style working” question.

p { } /* type (element) selector: every <p> */
.card { } /* class: every element with class="card" */
#header { } /* id: the element with id="header" */
* { } /* universal: every element */

Class is the workhorse. Use ids for JavaScript hooks and fragment links, not for styling — an id’s specificity is so high that overriding it later forces you into an arms race.

An element can carry many classes, and CSS does not care about the order in the attribute:

<button class="btn btn-primary is-loading">Save</button>
.btn { padding: 0.5rem 1rem; }
.btn-primary { background: navy; color: white; }
.is-loading { opacity: 0.6; pointer-events: none; }
[disabled] { } /* has the attribute at all */
[type="email"] { } /* exact value */
[class~="card"] { } /* value is one of a space-separated list */
[lang|="en"] { } /* value is "en" or starts with "en-" */
[href^="https://"] { } /* starts with */
[href$=".pdf"] { } /* ends with */
[href*="example"] { } /* contains */
[data-state="open" i]{ } /* i = case-insensitive match */

Useful in practice for styling by state without extra classes:

button[aria-expanded="true"] .chevron { transform: rotate(180deg); }
a[target="_blank"]::after { content: ""; }
input:not([type="checkbox"]):not([type="radio"]) { width: 100%; }

Write selectors adjacent with no space to require all of them on the same element:

a.button { } /* an <a> that also has class="button" */
input[type="text"].error { }

Combinators express relationships between elements.

Combinator Syntax Meaning
Descendant A B every B anywhere inside an A
Child A > B every B that is a direct child of an A
Next sibling A + B the B immediately after an A, same parent
Subsequent sibling A ~ B every B after an A, same parent
<article class="post">
<h2>Title</h2>
<p>Lead paragraph</p>
<p>Second paragraph</p>
<aside><p>Nested</p></aside>
</article>
.post p { color: #333; } /* all three <p>, including the nested one */
.post > p { color: #333; } /* only the two direct children */
h2 + p { font-size: 1.2rem; } /* only "Lead paragraph" */
h2 ~ p { margin-block: 1rem; } /* both direct-child paragraphs */

The + combinator gives you the classic “space between siblings, but not before the first one” pattern, sometimes called the owl:

.stack > * + * { margin-block-start: 1rem; }

A pseudo-class (single colon) matches elements based on state or position rather than markup.

a:hover { text-decoration: underline; }
a:active { color: red; } /* while mouse is down */
button:focus-visible { outline: 2px solid blue; } /* keyboard focus only */
input:disabled { opacity: 0.5; }
input:checked { accent-color: green; }
input:invalid { border-color: crimson; }
input:placeholder-shown { border-style: dashed; }
.card:focus-within { box-shadow: 0 0 0 2px blue; } /* focus is inside it */
:target { background: yellow; } /* matches #fragment target */
li:first-child { }
li:last-child { }
li:only-child { }
p:first-of-type { } /* first <p> among its siblings */
li:nth-child(3) { } /* the 3rd child */
li:nth-child(odd){ } /* 1st, 3rd, 5th … */
li:nth-child(2n) { } /* every 2nd */
li:nth-child(3n+1){ } /* 1st, 4th, 7th … */
li:nth-last-child(2) { } /* 2nd from the end */
p:empty { } /* no children and no text */

an+b reads as: for every integer n starting at 0, the child at index an+b matches. 3n+1 gives 1, 4, 7. Negative coefficients work too: -n+3 selects the first three.

:is() takes a selector list and matches if any of them match. It shortens repetitive selectors:

/* before */
article h1, article h2, article h3 { margin-block-start: 1.5em; }
/* after */
article :is(h1, h2, h3) { margin-block-start: 1.5em; }

:where() is identical except its specificity is always zero. That makes it the right tool for defaults you want to be trivially overridable:

:where(a) { color: inherit; } /* specificity 0,0,0 */
.link { color: blue; } /* wins easily */

:not() inverts:

button:not(.primary) { background: #eee; }
li:not(:last-child) { border-bottom: 1px solid #ddd; }
:not(.a, .b) { } /* selector list allowed */

Specificity rule for all three: :is() and :not() take the specificity of their most specific argument; :where() always contributes zero.

:is(#id, .cls) p /* specificity 1,0,1 — the #id dominates */
:where(#id, .cls) p /* specificity 0,0,1 */

A pseudo-element (double colon) styles a part of an element that is not in the DOM.

.quote::before { content: "\201C"; font-size: 2em; }
.quote::after { content: "\201D"; }
p::first-line { font-variant: small-caps; }
p::first-letter{ float: left; font-size: 3em; }
::selection { background: #ffe08a; }
input::placeholder { color: #999; }
::marker { color: crimson; } /* the list bullet/number */
::backdrop { background: rgb(0 0 0 / 0.5); } /* behind a <dialog> */

::before and ::after require a content property, even if it is empty. Without it, nothing is generated.

.badge::after {
content: ""; /* mandatory */
display: inline-block;
inline-size: 8px;
block-size: 8px;
border-radius: 50%;
background: currentColor;
}

Generated content is a child of the element, placed at the start (::before) or end (::after) of its content box. It cannot be selected by the user in a copy, is invisible to most assistive tech, and does not work on replaced elements<img>, <input>, <br>, <iframe> — because those have no content box to insert into.

Specificity is a three-part value written (a, b, c):

Bucket Counts
a — ids #id
b — classes .class, [attr], :hover, :nth-child(), :not(…) contributes its argument here if the argument is a class
c — types element names (div, p), pseudo-elements (::before)

The universal selector * and combinators (>, +, ~, whitespace) add nothing.

Compare left to right; the first difference decides. There is no carrying — 11 classes is (0,11,0), which still loses to (1,0,0).

* /* 0,0,0 */
li /* 0,0,1 */
ul li /* 0,0,2 */
ul > li::before /* 0,0,3 */
.nav li /* 0,1,1 */
li.active /* 0,1,1 — ties with the above; source order decides */
a[href^="#"]:hover /* 0,2,1 */
#nav .item a /* 1,1,1 */

Inline style="…" sits above all of these (sometimes described as (1,0,0,0)).

Appending !important to a declaration lifts it into a separate, higher band of the cascade:

.text { color: red !important; }
#main .text { color: blue; } /* loses — red wins */

Within the important band, normal rules apply again: specificity, then source order. So !important can be beaten only by another !important with higher specificity or later source order.

@layer lets you order groups of rules explicitly, above specificity. Declare the order once; later layers beat earlier layers regardless of how specific the selectors are.

@layer reset, base, components, utilities;
@layer components {
#sidebar .btn { background: navy; } /* specificity 1,1,0 */
}
@layer utilities {
.bg-white { background: white; } /* specificity 0,1,0 — still wins */
}

Unlayered styles beat all layered styles. Tailwind v4 uses native layers (theme, base, components, utilities) for exactly this reason: a one-class utility can reliably override a much more specific base rule.

For a given element and property, the browser sorts every applicable declaration by:

  1. Origin and importance. Lowest to highest priority: browser default (user-agent) normal → user normal → author normal → CSS animations → author !important → user !important → user-agent !important → CSS transitions. Note that !important reverses the origin order.
  2. Cascade layer. Later-declared layers win; unlayered wins over layered (and this reverses inside the !important band).
  3. Inline style. A style attribute beats stylesheet rules of the same origin.
  4. Specificity.
  5. Source order. Last one wins.

Source order means order in the final assembled stylesheet, including the order of <link> elements and @imports.

<link rel="stylesheet" href="theme.css" />
<link rel="stylesheet" href="overrides.css" /> <!-- later: wins on ties -->

Inherited properties are applied to descendants automatically when you do not set them. The set is fixed by the spec; the practical list:

  • Inherited: color, font-family, font-size, font-weight, font-style, line-height, letter-spacing, word-spacing, text-align, text-indent, text-transform, white-space, visibility, cursor, list-style, direction, and all custom properties.
  • Not inherited: everything box- or paint-related — margin, padding, border, background, width, height, display, position, overflow, box-shadow, opacity.

Every property accepts these:

.child {
color: inherit; /* take the parent's computed value, always */
color: initial; /* the property's spec-defined default (color -> canvastext) */
color: unset; /* inherit if the property is inherited, otherwise initial */
color: revert; /* roll back to the value from the previous cascade origin
(usually the browser default stylesheet) */
}

inherit is genuinely useful for forcing non-inherited properties down:

button {
font: inherit; /* buttons default to the OS font otherwise */
color: inherit;
background: none;
border: none;
}

revert is the right tool for undoing a reset: all: revert on a widget restores the browser’s default look for it, whereas all: unset strips everything and leaves an unstyled blob.

A custom property is any property whose name starts with --. Its value is an arbitrary token sequence, it inherits, and it is read with var().

:root {
--brand: #2563eb;
--space: 1rem;
--radius: 0.5rem;
}
.card {
background: var(--brand);
padding: var(--space);
border-radius: var(--radius);
}

:root is the <html> element (with a tiny specificity bump over html), which is the conventional place for global tokens.

Custom properties follow normal cascade and inheritance rules, so you can redefine them per subtree. This is the single most powerful thing about them:

.card { --pad: 1rem; padding: var(--pad); }
.card.compact { --pad: 0.5rem; } /* only changes this card and its descendants */

Theming becomes a matter of re-declaring variables, not re-declaring rules:

:root { --bg: white; --fg: #111; }
:root[data-theme="dark"] { --bg: #111; --fg: #eee; }
body { background: var(--bg); color: var(--fg); }

var() takes a second argument used when the property is not defined:

.el {
gap: var(--gap, 1rem);
color: var(--accent, var(--brand, black)); /* nested fallbacks are fine */
}

A fallback can itself contain commas; everything after the first comma is the fallback: var(--font, Helvetica, Arial, sans-serif).

  • Custom properties are case-sensitive: --Brand and --brand are different.
  • They are substituted at computed-value time, so they work anywhere a value goes — including inside calc(), gradients, and partial values.
  • They do not work for parts of a property name or for at-rule preludes. @media (min-width: var(--bp)) does not work.
  • If var() resolves to something invalid for the property, the declaration is invalid at computed-value time: the property falls back to its inherited value, or its initial value — not to the previous declaration in the block.
.el {
--w: 10; /* no unit */
width: 200px;
width: var(--w); /* invalid -> width becomes auto, not 200px */
}

Use calc() to attach a unit, or @property to declare a type:

.el { width: calc(var(--w) * 1px); }
const el = document.querySelector('.card');
el.style.setProperty('--pad', '2rem');
getComputedStyle(el).getPropertyValue('--pad'); // => " 2rem"

This is the clean way to pass runtime values into CSS without writing whole declarations from JS.

@property registers a custom property with a type, an initial value, and whether it inherits. The payoff is that typed properties can be animated and transitioned, which plain custom properties cannot.

@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.spinner {
background: conic-gradient(from var(--angle), red, blue, red);
transition: --angle 1s linear;
}
.spinner:hover { --angle: 360deg; }
  • Class selectors and combinators cover almost every real need; ids for styling create specificity you will regret.
  • :where() is zero-specificity, :is()/:not() inherit their most specific argument’s specificity.
  • ::before/::after need content and never work on replaced elements like <img>.
  • Specificity is (ids, classes, types), compared left to right, with no carrying.
  • The cascade order is origin/importance → layer → inline → specificity → source order.
  • Custom properties inherit and can be scoped per subtree; a bad var() falls back to inherited/initial, not to the previous declaration.