The CSS Specificity Graph
Quick answer: A CSS specificity graph plots every selector’s specificity on the Y axis against its position in the stylesheet on the X axis. A healthy stylesheet trends gently upward. Spikes mean a high-specificity selector landed early, and everything after it has to fight to override it. The shape tells you how maintainable the CSS is before you read a line of it.
Harry Roberts introduced the specificity graph on CSS Wizardry in October 2014, as part of the ITCSS architecture. It is the fastest CSS code review that exists: one chart, no reading, and you know whether the stylesheet is going to be pleasant to work in.
What a good one and a bad one look like
Healthy
Low to high, no reversals.
Jagged
Spikes early, overrides forever.
The reason the jagged graph is bad is mechanical, not aesthetic. When a spike appears at position 20 of 400, every one of the remaining 380 rules that wants to touch those properties must match or exceed that specificity. Authors discover this the hard way, one rule at a time, and the usual fix is !important — which creates a taller spike and starts the cycle again.
The thing most people get wrong about specificity
Specificity is not a number. It is three numbers compared left to right. A selector’s specificity is the triple (A, B, C):
| Slot | Counts | Example |
|---|---|---|
| A | ID selectors | #header |
| B | Classes, attribute selectors, pseudo-classes | .card, [disabled], :hover |
| C | Element types and pseudo-elements | div, ::before |
Comparison is column by column, and a higher column always wins outright. This is why the common shorthand of writing specificity as “100, 10, 1” and adding it up is wrong: eleven classes do not beat one ID. (0,11,0) loses to (1,0,0) every time, because the A column decides before B is even consulted. There is no carrying, and there is no base 10.
Three more rules that decide real arguments:
:where()contributes zero. Whatever is inside it — IDs included — adds nothing to any column.:is(),:not()and:has()take the specificity of their most specific argument. So:is(#a, p)scores(1,0,0), which surprises people who expected the cheap half to count.- Inline styles and
!importantsit outside the triple. An inlinestyleattribute beats any selector;!importantbeats that; and cascade layers are consulted before any of it.
Calculate a selector
Paste any selector — or a list of them, one per line — and see the triple. Everything runs locally.
Specificity calculator
One selector per line. Handles :where(), :is(), :not(), :has() and attribute selectors.
Results appear here, sorted by specificity.
Plot the graph for any live site
You do not need a build step or a stylesheet file. This walks the page’s own stylesheets, computes each selector’s specificity, and prints them in source order — the raw data behind the graph:
function spec(sel) {
const s = sel
.replace(/:where\([^)]*\)/g, '') // :where() is free
.replace(/\[[^\]]*\]/g, '[]'); // normalise attributes
return [
(s.match(/#[\w-]+/g) || []).length, // A: IDs
(s.match(/\.[\w-]+|\[\]|:(?!:)[\w-]+/g) || []).length, // B: classes, attrs, pseudo-classes
(s.match(/(^|[\s>+~(])[a-zA-Z][\w-]*|::[\w-]+/g) || []).length // C: elements
];
}
const rows = [...document.styleSheets]
.flatMap(sheet => { try { return [...sheet.cssRules] } catch { return [] } })
.filter(rule => rule.selectorText)
.flatMap(rule => rule.selectorText.split(',').map(s => s.trim()))
.map((selector, order) => {
const [a, b, c] = spec(selector);
return { order, selector, specificity: `${a},${b},${c}`, weight: a * 10000 + b * 100 + c };
});
console.table(rows.slice(0, 60)); // source order
console.table([...rows].sort((x, y) => y.weight - x.weight).slice(0, 20)); // worst offenders
The weight field is only for sorting — it uses a large base so no realistic number of classes can roll over into the ID column. Do not mistake it for how the cascade works; the cascade compares columns, as above.
The second table is the one that changes behaviour. The twenty most specific selectors in a stylesheet are almost always the twenty that cause the most pain, and they are usually fixable in an afternoon.
The visual version, without the console. CSS DNA renders the specificity graph for any page you are on, alongside a CSS health score, the !important count, duplicate selectors, orphaned custom properties and a ranked worst-offenders list. Add it free — the audit lives in the Pro tier, and everything else on this page is free forever.
Four fixes that flatten the graph
1. Wrap resets and library CSS in :where()
The single cheapest change available. A reset written as :where(button, input, select) contributes zero specificity, so any later rule beats it with one class and no fight:
/* before: (0,0,1) — you must out-specify it */
button { font: inherit; }
/* after: (0,0,0) — a single class wins */
:where(button) { font: inherit; }
2. Order with @layer instead of specificity
Cascade layers decide the winner before specificity is compared. Declare the order once and a low-specificity rule in a later layer beats a high-specificity rule in an earlier one:
@layer reset, base, components, utilities;
@layer components { #sidebar .nav a { color: blue; } } /* (1,1,1) */
@layer utilities { .text-red { color: red; } } /* (0,1,0) — wins */
That inversion is the entire point. It also means a third-party stylesheet dropped into an early layer can never out-shout your own code, which removes the most common reason people reach for !important.
3. Delete the ID selectors
An ID in a selector jumps the A column, and nothing but another ID or !important comes back from that. Keep IDs in the HTML for anchors, labels and JavaScript hooks. Style with classes. The change is usually mechanical: #sidebar becomes .sidebar.
4. Treat every !important as a bug report
Each one records a moment when the cascade was fought instead of used. Utility classes are the honest exception — a class whose entire job is to win. Everything else is worth a look; the fix is usually one of the three above.
What the graph does not tell you
Specificity is one axis of CSS health, and a flat graph on a bad stylesheet is still a bad stylesheet. Four things to check alongside it:
- Duplicate selectors. The same selector declared in six places is invisible on the graph and is where cascade bugs actually live.
- Orphaned custom properties. Variables defined and never referenced. Common after a rebrand, and each one is a decision nobody can safely delete without checking.
- Near-duplicate values. Four greys within a rounding error of each other, or a spacing scale with
15pxamong the multiples of four. See catching near-duplicate colors. - Unused rules. The graph counts every selector equally, including the ones that match nothing on any page.
Those four plus the graph are what a real CSS audit covers, and together they answer the question the graph only gestures at: is this stylesheet safe to change?
Frequently asked questions
What is a CSS specificity graph?
A CSS specificity graph is a chart plotting each selector’s specificity on the Y axis against its position in the stylesheet on the X axis. Introduced by Harry Roberts on CSS Wizardry in 2014 as part of the ITCSS methodology, it shows at a glance whether a stylesheet is ordered from low to high specificity. A steadily rising line is healthy; spikes indicate maintainability problems.
How do I read a specificity graph?
Look for reversals, not height. A line that climbs steadily from left to right is healthy, because later rules can override earlier ones through source order alone. A spike means a high-specificity selector appears early, so every subsequent rule touching those properties must match or exceed it.
How is CSS specificity calculated?
Specificity is a triple: A counts ID selectors, B counts classes, attribute selectors and pseudo-classes, and C counts element types and pseudo-elements. The columns are compared left to right, and a higher column wins outright — so (1,0,0) beats (0,11,0). The values do not add up into a single base-10 number.
Do multiple classes beat an ID selector?
No. Any number of classes loses to a single ID, because the ID column is compared first and decides the outcome before the class column is considered. Only another ID, an inline style, !important, or an earlier-losing cascade layer can override an ID-based rule.
Does :where() have zero specificity?
Yes. :where() always contributes zero to all three columns, regardless of what is inside it — including ID selectors. That makes it the standard tool for writing resets and library defaults that authors can override with a single class.
What specificity do :is(), :not() and :has() have?
All three take the specificity of their most specific argument. :is(#header, p) scores (1,0,0) because of the ID, even though a plain p would score (0,0,1). The pseudo-class itself adds nothing beyond its argument.
Do cascade layers override specificity?
Yes. Cascade layers are evaluated before specificity, so a low-specificity rule in a later layer beats a high-specificity rule in an earlier one. Declaring layer order with @layer reset, base, components, utilities; lets you control the cascade by architecture instead of by selector weight.
How many !important declarations are too many?
There is no threshold that applies to every codebase, but the useful distinction is by intent. Utility classes designed to win are legitimate. Every other !important records a moment when the cascade was fought rather than used, and is usually fixable with a cascade layer, a :where() wrapper, or by removing an ID from a selector.
How do I generate a specificity graph for a website?
Walk document.styleSheets in the console, split each rule’s selectorText on commas, compute the specificity triple per selector, and print them in source order — wrapping each sheet in try / catch so cross-origin stylesheets are skipped rather than throwing. Browser extensions such as CSS DNA render the same graph visually alongside a CSS health score.
Audit a stylesheet in one click
A specificity graph, a 0–100 CSS health score, !important counts, duplicate selectors and orphaned variables — for any page you are on, computed in your browser.