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.
Selector types
Section titled “Selector types”Type, class, id, universal
Section titled “Type, class, id, universal”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; }Attribute selectors
Section titled “Attribute selectors”[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%; }Compound selectors
Section titled “Compound selectors”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
Section titled “Combinators”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; }Pseudo-classes
Section titled “Pseudo-classes”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 */Structural
Section titled “Structural”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.
Logical: :is(), :where(), :not()
Section titled “Logical: :is(), :where(), :not()”: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 */Pseudo-elements
Section titled “Pseudo-elements”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
Section titled “Specificity”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)).
!important
Section titled “!important”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.
Cascade layers (@layer)
Section titled “Cascade layers (@layer)”@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.
The cascade, in order
Section titled “The cascade, in order”For a given element and property, the browser sorts every applicable declaration by:
- 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!importantreverses the origin order. - Cascade layer. Later-declared layers win; unlayered wins over layered (and this reverses inside the
!importantband). - Inline style. A
styleattribute beats stylesheet rules of the same origin. - Specificity.
- 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 -->Inheritance
Section titled “Inheritance”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.
The four global keywords
Section titled “The four global keywords”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.
Custom properties (CSS variables)
Section titled “Custom properties (CSS variables)”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); }Fallbacks
Section titled “Fallbacks”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).
Behaviour worth knowing
Section titled “Behaviour worth knowing”- Custom properties are case-sensitive:
--Brandand--brandare 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); }Setting them from JavaScript
Section titled “Setting them from JavaScript”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
Section titled “@property”@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; }Key points
Section titled “Key points”- 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/::afterneedcontentand 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.