Fixing CSS Specificity Conflicts
A specificity conflict is when you change a colour, margin or font size and nothing happens, because another rule somewhere else has a stronger selector and keeps winning. The usual reaction is to add an ID, another class or !important, which works once and makes the next change harder.
Over time the stylesheet fills with long selector chains and override rules that nobody dares delete. This guide explains how the browser actually picks a winner, how to find the rules causing the fights, and how to restructure them with cascade layers, zero-specificity selectors and flat class names.
How specificity problems show up
Specificity trouble rarely announces itself. You notice it through habits that build up in a codebase:
- A style change only "takes" when you add
!important. - Selectors such as
#main .content .sidebar .widget ul li a, often produced by nesting in Sass or Less. - Copies of the same rule with one more class added, each written to beat the one before.
- A component that looks right on one page and wrong on another, because a page-level rule with an ID outranks it there.
- Utility classes such as
.mt-0or.text-centerthat do nothing on some elements. - Theme updates that "break" your custom styles, when really the theme's selectors got slightly stronger than yours.
The CSS Checker scans for specificity issues such as heavy use of !important and overly
strong selectors. Treat its list as a map of where the fights are happening; the rest of this guide is
about ending them rather than winning each one.
How the browser decides which rule wins
Specificity is only one step in the cascade. When two declarations set the same property on the same element, the browser compares them in this order and stops at the first difference:
- Origin and importance. Your stylesheet beats the browser default; an
!importantdeclaration beats a normal one. - Cascade layer. For normal declarations, a later layer beats an earlier one, and styles not in any layer beat all layers.
- Specificity. The selector with the higher score wins.
- Order of appearance. If everything above is equal, the declaration that comes later wins.
Styles written in an element's style attribute outrank any selector in a stylesheet,
which is why inline styles added by JavaScript or a page builder are so hard to override without !important.
Reading a specificity score
A selector's specificity is three numbers, compared from left to right: IDs, then classes (including attribute selectors and pseudo-classes), then element names (including pseudo-elements). The first column that differs decides it; a single ID beats any number of classes.
| Selector | Score (IDs, classes, elements) | Notes |
|---|---|---|
a | 0, 0, 1 | One element |
.button | 0, 1, 0 | Beats any number of plain element names |
nav ul li a:hover | 0, 1, 4 | The pseudo-class counts as a class |
.card .button | 0, 2, 0 | Beats .button alone |
#header .button | 1, 1, 0 | The ID outranks every class-only selector |
:where(#header) .button | 0, 1, 0 | Anything inside :where() scores zero |
:is(#header, .top) .button | 1, 1, 0 | :is() takes the strongest argument |
* | 0, 0, 0 | The universal selector adds nothing |
The same "strongest argument" rule applies to :not() and :has(). Native
CSS nesting behaves as if the parent selector were wrapped in :is(), so nesting an ID-based
parent quietly gives every child rule an ID's weight.
Tracing a conflict in developer tools
Pick one concrete symptom, such as "the newsletter button is grey instead of green", and trace it:
- Right-click the element and choose Inspect.
- In the Styles pane, find the property you expected to apply. It will be struck through.
- Scroll up the pane to find the declaration that is not struck through. That is the winner, and the file and line are shown next to it.
- Hover over both selectors to compare their specificity, and check whether either sits inside an
@layerblock or carries!important. - Open the Computed pane and expand the property to see every declaration that tried to set it, in the order the browser considered them.
Write down the pair: the rule you wanted and the rule that won. After a handful of these you usually
see a pattern, such as a theme file using IDs, a plugin using long chains, or a legacy stylesheet sprinkling
!important. That pattern tells you which structural fix to apply first.
style attribute,
no amount of selector work will help. Find the script, plugin or builder setting that writes the inline
style and change it there.Cascade layers: controlling order without raising scores
Cascade layers are the most useful tool for specificity problems added to CSS in years, and they are supported in all current major browsers. A layer is a named bucket of styles. You declare the order of the buckets once, and from then on a rule in a later layer beats a rule in an earlier layer, however strong the earlier rule's selector is.
/* Declare the order first, at the very top of the first stylesheet */
@layer reset, vendor, base, components, utilities;
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
}
@layer base {
a { color: #1d4ed8; }
}
@layer components {
.card a { color: inherit; }
}
@layer utilities {
.text-danger { color: #b91c1c; }
}
With this structure, .text-danger on a link inside a card is red, even though .card a
has higher specificity, because the utilities layer comes later. Specificity still matters inside each
layer, but it no longer decides fights between layers.
Putting code you do not control into a layer
You can place a whole imported stylesheet into a layer, which is ideal for frameworks and plugin CSS with aggressive selectors:
@import url("vendor/datepicker.css") layer(vendor);
Every rule in that file now loses to your base, component and utility layers, however many IDs it uses.
Two rules that surprise people
- Unlayered styles win. Any normal rule not inside a layer beats every layered rule. That is handy during migration, because your existing CSS keeps working, but it also means a forgotten unlayered file can override everything.
- Important declarations reverse the order. An
!importantdeclaration in an early layer beats an!importantdeclaration in a later layer, and layered important declarations beat unlayered important ones. This lets a reset layer protect something like a hidden attribute with!importantthat later layers cannot undo.
The fix, step by step
Step 1
Find the rules that are fighting
Run
the CSS Checker and export its specificity findings, then search your source for !important
and for selectors containing #. For each visual bug you know about, inspect the element
and note the rule you expected and the rule that actually won. Group the pairs by source, such as theme,
plugin, framework or your own files, because the source decides whether you can edit the rule or can
only contain it.
Step 2
Read the specificity of each selector
Hover
over each selector in developer tools to see its score as three numbers for IDs, classes and elements.
Anything with an ID, or with more than three classes, is a candidate for rewriting. Watch for hidden
weight: nested Sass that compiles into long chains, :is() or :not() containing
an ID, and native nesting under an ID parent. Record the target score for each component, ideally a single
class.
Step 3
Declare an order of cascade layers
At
the very top of your first stylesheet, add one statement such as @layer reset, vendor, base, components, utilities;
so the order is fixed before any other CSS loads. Then wrap existing files in the matching layer, starting
with the lowest-risk ones. Leave anything you have not migrated yet unlayered for now, remembering that
unlayered rules beat layered ones, and move them in one at a time.
Step 4
Move third-party and reset CSS into low layers
Import framework, plugin and widget stylesheets with @import url(...) layer(vendor);
or wrap them in an @layer vendor block, so their heavy selectors lose to your own base and
component styles. Wrap resets and element defaults in :where() as well, for example :where(ul, ol) { padding: 0; },
so they score zero and any class can override them. This alone often removes the reason for most of your
override rules.
Step 5
Flatten your own selectors
Rewrite
component selectors so each targets one class, using a naming convention such as .card,
.card__title and .card--featured instead of #content .card h3.
Replace IDs in selectors with classes, since IDs belong in anchors and JavaScript hooks. Limit Sass nesting
to one level for states and pseudo-elements. Check each rewritten component on every template where it
appears, in both desktop and mobile widths, before moving on.
Step 6
Remove !important one group at a time
With layers and flat selectors in place, most !important flags have nothing
left to beat. Delete them for one component or one file at a time, reload, and compare the page with
a screenshot taken before the change. Keep !important only where it is intentional, such
as a utility that must always apply, a reduced-motion override, or an override of an inline style you
genuinely cannot change at source.
Step 7
Add lint rules so it stays fixed
Configure
Stylelint with selector-max-id set to 0, selector-max-specificity set to something
like 0,3,0, selector-max-compound-selectors set to 3 and declaration-no-important
turned on with named exceptions. Run it in your editor and in continuous integration so new code cannot
reintroduce the patterns. Then run the CSS Checker again and confirm the specificity findings for your
own files have dropped, leaving only the vendor code you have deliberately contained.
A before and after
Here is a typical tangle from a small business site, where a promotional button has to fight the theme's link styles:
/* Theme */
#page .entry-content a { color: #2563eb; text-decoration: underline; }
/* Someone's fix */
.promo-box .btn-offer { color: #fff; } /* loses: 0,2,0 vs 1,1,1 */
#page .promo-box a.btn-offer { color: #fff; } /* wins, now 1,2,1 */
.btn-offer.is-disabled { color: #9ca3af !important; } /* needed to beat the line above */
And the same result using layers and flat selectors:
@layer theme, components, states;
@import url("theme.css") layer(theme);
@layer components {
.btn-offer { color: #fff; text-decoration: none; }
}
@layer states {
.is-disabled { color: #9ca3af; }
}
No IDs, no !important, and the disabled state wins because of where it sits, not because
of how heavy its selector is. When a new state is needed next year, it goes in the states layer with
a single class.
@import for the theme file, because it is enqueued
by a CMS for example, you can still wrap your own CSS in layers. Remember that the theme's unlayered
rules will then win, so use this approach only once the theme CSS is layered or you have raised your
own rules deliberately.Platform notes
WordPress and page builders
Themes and builders often use ID-based wrappers and write inline styles for colours and spacing chosen in their settings panels. Set the value in the builder where possible rather than overriding it in CSS. For theme CSS you cannot edit, a child theme stylesheet enqueued after the parent, with selectors matching the parent's weight, is still the most predictable route. More on this in CSS in WordPress themes.
Component frameworks
CSS Modules, scoped styles in Vue or Svelte, and utility-first frameworks all keep specificity low by design, usually at one generated class. Conflicts there tend to come from global stylesheets imported alongside the components, or from the order in which bundled CSS files load. The React and Next.js guide covers load-order issues specific to those builds.
Utility frameworks
Utility classes need to win over component styles, which is why some frameworks offer an "important"
mode that adds !important to every utility. Current versions of popular utility frameworks
build on cascade layers instead, which achieves the same thing without making the utilities impossible
to override later.
Scoping styles so they do not leak
Many specificity fights begin as leaks: a rule written for one area of the site also matches elements elsewhere, and someone raises specificity to stop it. Tighter scoping removes the leak at source.
- One class per component. A class that names the component is scoped by convention.
- The
@scoperule. It limits styles to a subtree, and can stop at an inner boundary so a card's styles do not leak into content nested inside it. Browser support has been arriving in stages, so check Can I Use and provide a fallback if you rely on it. - Build-time scoping. CSS Modules and framework scoped styles rename classes so they cannot collide.
- Shadow DOM. Web components isolate their styles completely, which is powerful but also blocks global theme styles unless you pass them in through custom properties.
Scoping and layers work well together: layers settle which group of styles wins, and scoping makes sure each rule only matches what it was written for.
1. Why is my CSS not being applied?
Usually another rule is setting the same property with a stronger claim. Inspect the element and look in the Styles pane: your declaration will be struck through, and the rule above it that is not struck through is the winner. Compare the two selectors' specificity, check whether either is in a cascade layer or marked important, and check whether the winner is an inline style. Less often, the declaration itself is invalid or the selector does not match the element at all.
2. Is using !important always bad?
No, but it should be rare and deliberate. It is reasonable on utility classes that must always apply, on accessibility overrides such as reduced motion, and occasionally to beat an inline style you cannot change at source. It becomes a problem when it is used to win ordinary fights between your own rules, because the only way to override it is another important declaration. Cascade layers and lower-specificity selectors solve those fights without starting an arms race.
3. How do cascade layers differ from specificity?
Specificity compares selectors: an ID beats a class, a class beats an element name. Cascade layers compare groups of styles: you declare an order such as reset, base, components and utilities, and any normal rule in a later layer beats any normal rule in an earlier layer, whatever their selectors. The browser checks layers before specificity, so layers let you control which group of styles wins without making individual selectors heavier.
4. What does :where() do and when should I use it?
The :where() pseudo-class matches exactly like :is(), but it contributes zero specificity, however many IDs or classes are inside it. That makes it ideal for defaults and resets that you want to be easy to override, such as list padding, link colours inside article text, or base heading sizes. Any class applied later will beat a rule written with :where(), so components can style those elements without raising their own specificity.
5. Should I stop using IDs in CSS?
For styling, mostly yes. A single ID outweighs any number of classes, so an ID in a selector makes that rule very hard to override without adding another ID or an important flag. Keep IDs for what they are good at: fragment links, form label associations and JavaScript hooks. Style the same elements with classes instead. If you must target an element that only has an ID, an attribute selector such as [id="header"] carries only class-level weight.
6. Do cascade layers work in all browsers?
Cascade layers are supported in the current versions of Chrome, Edge, Firefox and Safari, and have been for some time. Very old browser versions that do not understand @layer will ignore the layered rules, so if your analytics show significant traffic from browsers several years out of date, test before relying on them. For most sites the practical question is not support but migration, since unlayered styles beat layered ones until everything has been moved in.
7. Why does my theme update keep breaking my custom CSS?
Your overrides were probably written to beat the theme's selectors at one point in time, with the same or slightly higher specificity. When an update changes a selector, adds a wrapper class or switches a rule to an ID, your override loses. Putting the theme's CSS into an earlier cascade layer, or keeping your changes in a child theme that loads after the parent with deliberately matched selectors, makes your styles win because of order rather than a fragile score.
The CSS Checker scans for specificity issues alongside W3C validation, render-blocking and unused CSS, vendor-prefix gaps and stylesheet size, so you can see where rules are fighting before you refactor.
Open CSS Checker Fixes โ