The most common confusion when writing CSS isn’t “what value should this property take,” but “why didn’t the rule I wrote take effect.” The answer is almost always the same thing: the browser matched more than one rule to the same element, and it picked one you didn’t expect. To predict the result, you need to know how selectors match elements, and how ties are broken when multiple rules hit the same element.

1. How Basic Selectors Match Elements

1.1 Type, class, ID, and the universal selector

A type selector matches by tag name — for example, p matches every <p> element. A class selector uses . plus a class name — for example, .warning matches elements with class="warning" or class="alert warning"; a class attribute can contain multiple space-separated classes, and matching just one of them is enough. An ID selector uses # plus an id value, like #main-nav; the same id is only supposed to appear once in a document, so ID selectors are usually used to target a single element. The universal selector * matches any element, and is commonly seen in style resets (for example, setting box-sizing to border-box uniformly across every element).

1.2 Attribute selectors

An attribute selector matches an element’s HTML attributes directly, not just class or id. [attr] matches any element that has the attribute (regardless of its value); [attr=value] matches when the attribute value equals it exactly; [attr^=value], [attr$=value], and [attr*=value] match a value starting with, ending with, or containing the given substring anywhere, respectively; [attr~=value] matches when a space-separated list of values contains the given word. Forms commonly use patterns like input[type="email"] or a[href^="https://"], letting you target specific elements without adding an extra class. Adding the i flag (for example, [lang="en" i]) makes the value comparison case-insensitive.

2. Combinators: Describing Relationships Between Elements

The descendant combinator (a space) matches a descendant at any depth: article p matches every p at any level inside article, no matter how many levels deep. The child combinator > only matches direct children: ul > li only matches li elements that are direct children of ul, not li elements nested inside a nested ul. The adjacent sibling combinator + matches the very next element at the same level immediately following: h2 + p only matches the one p immediately following an h2. The general sibling combinator ~ matches every matching element at the same level that comes after the given element, without requiring adjacency: h2 ~ p matches every p under the same parent that comes after an h2. Telling descendant apart from child, and adjacent apart from general sibling, is the key to avoiding a selector that matches “too broadly” or “too narrowly.”

3. Pseudo-Classes and Pseudo-Elements

3.1 Common pseudo-classes

A pseudo-class starts with : and matches an element’s current state or its position within the document structure, rather than a fixed attribute it inherently has. State-based examples include :hover, :focus, :checked, and :disabled; structural examples include :first-child, :last-child, :nth-child(2n) (matching every even-numbered child), and :only-child; :not(selector) excludes elements that match the given selector. These pseudo-classes let styling respond to interaction or structure without needing extra JavaScript to toggle a class.

3.2 The difference between pseudo-elements and pseudo-classes

A pseudo-element starts with :: (the older single-colon syntax is still allowed for compatibility with early browsers) and doesn’t match an element that actually exists in the document, but rather some sub-part of an element — for example, ::before and ::after insert generated content before or after an element’s content (they only display when paired with the content property), ::first-line and ::first-letter match the first line or first character of text content, and ::selection styles text as the user is selecting it. A pseudo-class selects “an existing element that meets some condition”; a pseudo-element selects “a part of an element that doesn’t necessarily correspond to a real DOM node” — this is the most fundamental difference between the two.

3.3 Modern selectors: :is(), :where(), and :has()

:is(selector-list) and :where(selector-list) merge a group of selectors into one — for example, :is(h1, h2, h3) a is equivalent to h1 a, h2 a, h3 a but more concise to write. The difference between them is specificity: :is()’s specificity equals the highest specificity among its arguments, while :where() always contributes zero specificity — this is exactly why :where() exists, letting authors write structurally complex selectors without accidentally inflating specificity to the point where it’s hard to override. :has(selector) is a relational pseudo-class that matches as soon as the given relative selector matches at least one result within the current element — effectively the long-missing “parent selector” capability. For example, li:has(> img) matches “an li that directly contains an img child.” As of 2023, :has() is supported across all major browsers.

4. Selector Lists and Specificity

4.1 Selector lists

A comma merges multiple selectors into one rule’s matching condition — for example, h1, h2, h3 { margin-top: 1.5em; } applies the same style to all three heading levels, equivalent to writing three separate rules.

4.2 How specificity is calculated

When multiple rules match the same element and set the same property, the browser has to decide which one to use — this is specificity. Specificity is a triple (a,b,c)(a,b,c): aa is the number of ID selectors, bb is the number of class, attribute, and pseudo-class selectors, and cc is the number of type selectors and pseudo-elements. Comparison checks aa first; if aa is equal, it compares bb; only if bb is also equal does it compare cc. Combinators themselves (space, >, +, ~) don’t count toward specificity.

Take #nav .menu li a:hover as an example: there’s 1 ID (#nav), 2 classes/pseudo-classes (.menu, :hover), and 2 types (li, a), giving specificity (1,2,2)(1,2,2). Compare it to .nav-menu a.active: this rule has no ID, 2 classes (.nav-menu, .active), and 1 type (a), giving specificity (0,2,1)(0,2,1). Since a=1>0a=1>0, the first rule wins, no matter how many more classes the second rule piles on — this is exactly why “one ID selector beats a whole string of class selectors.” An inline style (the HTML style attribute) has higher priority than any selector; !important overrides the specificity comparison entirely, though when multiple !important declarations conflict, the tie is still broken by specificity and then source order, as described next.

5. Cascade Order and Inheritance

5.1 Cascade order

When the same property is set by multiple rules with equal specificity, what decides the winner is cascade order: first, whether the declaration is marked !important (an !important declaration as a whole beats one that isn’t); then specificity; and if specificity is also equal, the rule that appears later (whether later in the same stylesheet, or in a stylesheet loaded later) overrides the one that appears earlier. The full cascade specification also accounts for style origin (browser defaults, user settings, author stylesheets) and the newer @layer cascade layers mechanism, but in day-to-day development, these three steps — importance → specificity → source order — already explain the vast majority of cases.

5.2 Inheritance, and inherit, initial, unset

Not every property is inherited from a parent element. Text-related properties (color, font-family, font-size, line-height, text-align, and so on) are inherited by default; layout-related properties (margin, padding, border, width, background, display, and so on) are not inherited by default — otherwise every child element would unexpectedly pick up its parent’s margins or background. The inherit keyword forces a property to inherit its parent’s computed value, initial resets a property back to its spec-defined initial value, and unset behaves like inherit or initial depending on whether that particular property is inherited by default.

6. Debugging Why a Selector Didn’t Take Effect

When a rule doesn’t take effect, it’s usually not a syntax error — it’s being overridden by another rule with higher specificity, or one that comes later in the cascade order. The browser devtools’ “Styles” panel lists every matched rule, with overridden declarations shown with a strikethrough; switching to the “Computed” panel shows the final value that actually took effect for each property, and lets you trace which rule it came from. Common pitfalls include: forgetting that an ID selector’s specificity far outweighs a long string of classes; overusing !important, which forces any later attempt to override it into an endless arms race of more !important; inline styles written dynamically by JavaScript frameworks, which outrank any stylesheet rule; and typos in the selector (mismatched class-name casing, forgetting a . or #), which cause the selector to match nothing at all — rather than matching but losing. These two situations look completely different in devtools, so figuring out whether it’s “didn’t match at all” or “matched but lost” is the first step to fixing it correctly.