CSS

Why Your CSS Doesn't Work: Specificity Explained

css-specificity-guide

You write a CSS rule. It should work. It doesn't. You add !important. It works, but now you feel dirty. Three months later, someone else adds !important to override your !important, and the whole stylesheet is a war zone.

Sound familiar? The root cause is almost always the same thing: you don't fully understand specificity. Not as an insult — most developers don't. It's one of those topics everyone thinks they know until they actually have to debug a real conflict. Let's fix that permanently.

How Specificity Is Calculated

Every CSS selector gets a specificity score. When two rules target the same element with the same property, the one with the higher specificity wins. It's that simple — in theory.

The specificity score is calculated across three categories (sometimes shown as three numbers like 1-2-3):

  1. ID selectors (#header, #main) — each one adds 1-0-0
  2. Class selectors, attribute selectors, pseudo-classes (.btn, [type="text"], :hover) — each adds 0-1-0
  3. Element selectors, pseudo-elements (div, p, ::before) — each adds 0-0-1

The universal selector (*), combinators (>, +, ~), and :where() contribute zero specificity.

Let's Score Some Selectors

/* Specificity: 0-0-1 */
p { color: black; }

/* Specificity: 0-1-0 */
.text { color: blue; }

/* Specificity: 1-0-0 */
#intro { color: red; }

/* Specificity: 0-1-1 */
p.text { color: green; }

/* Specificity: 1-1-1 */
#intro p.text { color: purple; }

/* Specificity: 0-2-1 */
.card .text:hover { color: orange; }

/* Specificity: 0-1-3 */
div ul li.active { color: teal; }

The comparison works left to right. 1-0-0 always beats 0-99-99. An ID selector outweighs any number of class selectors. A class selector outweighs any number of element selectors. There's no amount of lower-tier selectors that can "add up" to beat a higher tier.

Quick mental model: Think of specificity like a three-digit number where each digit can go up to infinity. The leftmost digit (IDs) always dominates, just like 100 is always more than 099 regardless of the last two digits.

The Cascade Order — Specificity Isn't Everything

Specificity is just one factor. The full cascade algorithm considers multiple things in this order:

  1. Origin and importance — browser defaults < user styles < author styles; !important flips the order
  2. @layer order — cascade layers (more on this below)
  3. Specificity — our three-number score
  4. Source order — when specificity is equal, the last rule wins

That last point catches people off guard. If two selectors have identical specificity, the one that appears later in the stylesheet wins. This is why the order you import CSS files matters.

/* Both have specificity 0-1-0 */
.btn { background: blue; }
.btn { background: red; }  /* ← This one wins (later in source) */

The !important Escape Hatch (And Why You Should Avoid It)

!important overrides all specificity. It forces a declaration to win regardless of selector weight. Sounds great until you realize there's no !more-important.

.btn {
  background: blue !important; /* Beats everything... */
}

#main .primary-btn {
  background: red; /* ...even this, despite higher specificity */
}

The only way to override !important is with another !important rule that has equal or higher specificity, or that comes later in source order. That's how you end up in an !important arms race.

When !important is actually okay:

In your own application CSS? Almost never. If you need !important, your selector architecture probably needs rethinking.

:is() and :where() — Modern Specificity Control

These two pseudo-classes look similar but behave very differently when it comes to specificity. Understanding the difference is a game-changer.

:is() — Takes the Highest Specificity

/* Specificity: 1-0-0 (takes the #header ID specificity) */
:is(#header, .nav, footer) a {
  color: blue;
}

:is() takes on the specificity of its most specific argument. In the example above, #header is an ID selector (1-0-0), so the entire :is() block gets that specificity — even when matching .nav a or footer a.

:where() — Always Zero Specificity

/* Specificity: 0-0-1 (just the 'a' element selector) */
:where(#header, .nav, footer) a {
  color: blue;
}

:where() contributes exactly zero specificity. This is incredibly useful for writing base styles that are easy to override. Many CSS reset stylesheets now use :where() for this reason.

/* Easy to override — zero specificity from :where() */
:where(.card) {
  padding: 20px;
  border-radius: 8px;
  background: white;
}

/* Any class selector overrides the above */
.special-card {
  padding: 40px; /* Wins easily, 0-1-0 vs 0-0-0 */
}

Rule of thumb: Use :is() for convenience (shorter selectors, same behavior). Use :where() when you intentionally want low specificity — like in default styles, resets, or library CSS that consumers need to override.

@layer — The Cascade's New Superpower

Cascade layers, introduced with @layer, let you group your CSS into named layers with explicit ordering. Styles in later layers override earlier ones, regardless of specificity.

/* Define layer order */
@layer reset, base, components, utilities;

@layer reset {
  * { margin: 0; padding: 0; box-sizing: border-box; }
}

@layer base {
  body { font-family: 'Inter', sans-serif; }
  a { color: #3b82f6; }
}

@layer components {
  /* Even a simple class here beats anything in 'base' */
  .card { padding: 24px; border-radius: 12px; }
  .btn { padding: 12px 24px; }
}

@layer utilities {
  /* Utilities always win, no !important needed */
  .text-center { text-align: center; }
  .hidden { display: none; }
}

This is huge. With layers, a simple .hidden class in the utilities layer will override a #sidebar .nav-item.active rule in the components layer — without needing !important. The layer order handles precedence.

I've started using @layer on every new project. It eliminates most specificity headaches before they start.

Debugging Specificity Issues

When your styles aren't applying, here's my debugging checklist:

1. Chrome DevTools — Your Best Friend

Right-click the element → Inspect → look at the Styles panel. Chrome shows all matching rules in order of priority, with overridden declarations crossed out. It even tells you which rule is winning and why.

2. Check for Inline Styles

Inline styles (style="" attribute) have a specificity of 1-0-0-0 — higher than any selector. If a framework or JavaScript is adding inline styles, your stylesheet rules won't win without !important.

<!-- This inline style beats #main .card .title {} -->
<h2 style="color: red;">Title</h2>

3. Count the Selectors

When two rules conflict, count the IDs, classes, and elements in each selector:

/* Selector A: 0 IDs, 2 classes, 1 element = 0-2-1 */
.card .title span { }

/* Selector B: 1 ID, 0 classes, 0 elements = 1-0-0 */
#page-title { }

/* B wins — IDs always beat classes */

4. Check Source Order

If specificities match, the last rule in source order wins. This includes the order of your <link> tags. A stylesheet loaded later can override an earlier one at the same specificity.

Practical Strategies for Clean Specificity

After years of fighting CSS specificity, here's what actually works:

Visualize Your CSS Layouts

Test your CSS specificity strategies visually. Convert any HTML with styled components into crisp images for docs and presentations.

Try HTML to PNG Converter →

A Real-World Scenario

Let's put it all together. You're using a component library that styles buttons like this:

/* Library CSS (loaded first) */
.ui-button {
  background: #6b7280;
  color: white;
  padding: 10px 20px;
  border-radius: 6px;
}

You want to make your primary buttons blue:

/* Your CSS (loaded after) */
.primary {
  background: #3b82f6;
}

This works! Both selectors have specificity 0-1-0, and yours comes later. But then the library updates and adds this:

/* Updated library CSS */
button.ui-button {
  background: #6b7280; /* Now specificity 0-1-1 */
}

Your .primary class (0-1-0) loses. You could bump your selector to button.primary, but that starts a specificity arms race. The better solution?

@layer library, app;

@layer library {
  @import url('library.css');
}

@layer app {
  .primary {
    background: #3b82f6; /* Always wins over library layer */
  }
}

Layer ordering solves the problem cleanly, no matter how specific the library's selectors get.

CSS specificity isn't hard once you stop treating it as magic and start treating it as math. Count the IDs, classes, and elements. Understand source order. Use modern tools like :where() and @layer. And for the love of clean code, stop reaching for !important as a first resort.

Sachin Bhanushali
Written by

Sachin Bhanushali

Full-stack developer and creator of HTMLtoImages. Building free, privacy-first developer tools that run entirely in your browser.