Skip to content
yutils

How CSS Selectors and Specificity Work

Why one rule wins over another — selector matching, the (a-b-c) specificity calculation, the cascade order, :where() with zero specificity, !important escape hatches, and how @layer brings order back.

~8 min read

Which color wins?

#main p { color: blue; }
.intro    { color: red; }

<p id="main" class="intro">Hello</p>
                            ↓
                          which color?

Answer: blue. Why? Because #main p is more specific. What does "more specific" actually mean? This guide walks through CSS selector matching, the (a, b, c) specificity calculation, the cascade order, :where()'s zero specificity, !important as the last resort, and how @layer brings sanity to large codebases.

The cascade — how CSS picks a winner

When multiple rules match an element, this order decides:

  1. Origin & importance — User agent / User / Author. !important flipped at each level
  2. Layer order — explicit @layer ordering (modern addition)
  3. Specificity — the (a, b, c) tuple
  4. Source order — last declared wins among ties

Specificity — the (a, b, c) tuple

Per selector:

a = number of ID selectors           (#main = 1)
b = classes / attributes / pseudo-classes  (.intro, [type], :hover = 1 each)
c = types / pseudo-elements          (p, ::before = 1 each)

Comparison:
(1, 0, 0) > (0, 99, 99)    ← one ID beats 99 classes
(0, 1, 0) > (0, 0, 99)     ← one class beats 99 types

Specificity of common selectors:

Selector(a, b, c)
*(0, 0, 0)
p(0, 0, 1)
p::before(0, 0, 2)
.intro(0, 1, 0)
p.intro(0, 1, 1)
[type="text"](0, 1, 0)
:hover(0, 1, 0)
#main(1, 0, 0)
#main p(1, 0, 1)
#main #header(2, 0, 0)

Inline style="..." sits at a separate ~1000 tier — beats almost every selector. !important trumps everything.

:where() — zero-specificity magic

Standardized in 2021. A gift for framework authors:

/* Regular :is() — adopts the highest inner specificity */
:is(.btn, #header) p     ← (1, 0, 1)  ← #header dominates

/* :where() — always (0, 0, 0) */
:where(.btn, #header) p  ← (0, 0, 1)  ← ignores inner specificity

Where it shines:

  • CSS resets / frameworks — users override with plain selectors
  • Tailwind utility wrappers — sidestep specificity wars
  • Large component libraries — keep base styles easy to override
/* CSS reset */
:where(h1, h2, h3, h4, h5, h6) {
  margin: 0;
  font-weight: 600;
}

/* User override — plain selectors work */
h1 { margin: 1rem; }     /* (0, 0, 1) > (0, 0, 0) → wins */

!important — the last resort

p { color: blue !important; }
#main p { color: red; }

→ blue (important wins)

# When important meets important?
p { color: blue !important; }     (0, 0, 1)
#main p { color: red !important; } (1, 0, 1) ← wins

Important cascade:

  1. User agent (browser default) important
  2. User stylesheet important
  3. Author CSS important
  4. Animations
  5. Author CSS (normal)
  6. User stylesheet (normal)
  7. User agent (normal)

For accessibility, user-stylesheet important beats author important — a vision-impaired user can force a font size that even !important author CSS can't undo.

@layer — modern cascade control (2022)

Specificity + source order isn't enough for big projects. @layer adds explicit ordering:

@layer reset, framework, components, utilities;

@layer reset {
  * { margin: 0; }
}

@layer framework {
  .btn { background: blue; }   /* (0, 1, 0) */
}

@layer components {
  .btn-primary { background: green; }  /* (0, 1, 0) */
}

@layer utilities {
  .bg-red { background: red !important; }
}

/* Result — layer order trumps specificity:
   utilities > components > framework > reset
   Specificity only matters within a single layer. */

Wins:

  • Frameworks (Bootstrap, Tailwind) sit above reset cleanly
  • Component CSS overrides framework without !important
  • Inside a single layer, specificity still applies normally

Tailwind v4 ships with three layers by default (base / components / utilities). Bootstrap 5.3+ adopted layers too.

The cascade's partner — inheritance

Separate from specificity. Some properties inherit from the parent:

Inheritable:
  color, font, line-height, letter-spacing, visibility, cursor, ...

Non-inheritable:
  background, border, margin, padding, position, display, ...

When no rule matches, the parent's inherited value applies. A matching rule beats the inherited value.

Forcing inheritance:

* {
  color: inherit;  /* every element inherits color from its parent */
}

button {
  background: inherit;  /* override the browser default bg */
}

Debugging specificity in practice

1. Browser DevTools

The Computed tab in the element inspector shows which rule wins. Strike-through marks the losers.

2. Specificity calculator

Compute (a, b, c) for any selector. Keegan Street's specificity.keegan.st is the classic.

3. "Why isn't this rule applying?" checklist

  1. Typo? Class name correct?
  2. Specificity lower than the actual winner? DevTools strike-through tells you
  3. Cascade order — same specificity → last declared wins. Are you earlier in the file?
  4. Inline style? style="..." takes priority
  5. Who added !important?
  6. Inside an @layer? Check the layer order

Common pitfalls

1. ID overuse

/* Bad — ID is too specific */
#sidebar { width: 200px; }
.sidebar-collapsed { width: 50px; }   /* doesn't apply — (0,1,0) < (1,0,0) */

/* Good — class only */
.sidebar { width: 200px; }
.sidebar.collapsed { width: 50px; }   /* (0,2,0) > (0,1,0) → wins */

2. Deep nesting

/* Bad */
.header .nav .menu .item a { color: blue; }   /* (0, 4, 1) */

/* Anyone overriding needs (0, 5, 0)+ → painful */

/* Good */
.menu-item { color: blue; }                   /* (0, 1, 0) */

3. !important inflation

Once you add !important to "fix" one rule, the next override needs another. Specificity loses meaning. Audit the cascade before reaching for it.

4. Tailwind + custom CSS collisions

<div class="bg-blue-500" style="background: red">
                ↓
                inline style wins (Tailwind class < inline)

<div class="bg-blue-500">
<style>
  .bg-blue-500 { background: red !important; }
</style>
                ↓
                same specificity + important → source order decides

Tailwind v4's @layer utilities structure cleans this up.

5. CSS-in-JS hash classes

styled-components / emotion generate .css-abc123 classes with specificity (0, 1, 0). User overrides need at least (0, 1, 0) to win. Wrapping styles in :where() drops the inner specificity to (0, 0, 0), making overrides trivial.

How selectors actually match

For performance, browsers match selectors right-to-left:

Selector: .container .item .button

Start from every .button in the DOM
  → any ancestor with .item?
    → any ancestor of that ancestor with .container?
      → match
    → otherwise skip

Deep nesting is inefficient — every candidate element walks the ancestor chain. Modern browsers index aggressively but flat selectors still win.

References

Summary

  • Decision order — origin/importance → layer → specificity → source order.
  • Specificity is the tuple (a, b, c). a = IDs, b = classes / attributes / pseudo-classes, c = types / pseudo-elements.
  • Inline style sits at a separate ~1000 tier. !important beats everything.
  • :where() = zero specificity. Useful for resets and frameworks.
  • @layer (2022) is explicit cascade control. Layer order beats specificity. Standard for large CSS codebases.
  • Inheritance is separate — some properties pass through; others don't.
  • Debug with DevTools' Computed tab + specificity calculators.
  • Try it — CSS Formatter to tidy CSS and eyeball each selector's (a, b, c).
Back to guides