CSS Specificity Calculation Rules

Introduction

CSS specificity is the selector-comparison system used when multiple declarations have already reached the same relevant cascade category and target the same property on the same element. It answers a focused question: among otherwise comparable rules, which selector is more specific? Understanding that question helps developers predict results, diagnose crossed-out declarations, and avoid fragile chains of IDs, ancestors, and !important.

Specificity is often described as a score, but it is not one ordinary decimal number. Modern selector specifications represent it as three ordered components, commonly written A-B-C. IDs contribute to A; classes, attributes, and pseudo-classes contribute to B; type selectors and pseudo-elements contribute to C. The browser compares each column from left to right. Modern selectors such as :is(), :where(), :has(), :nth-child(... of ...), and native CSS nesting require additional rules that every current specificity guide should explain.

What Is CSS Specificity?

Specificity is the weight assigned to a selector for a particular matching process. When two declarations share the same origin, importance, encapsulation context, and cascade-layer priority, the declaration attached to the more specific matching selector wins. If their specificity is equal, scoping proximity and then source order can decide.

Specificity belongs to selectors, not to properties or complete stylesheets. Each selector in a comma-separated list normally has its own specificity. A rule can win for one property and lose for another because different competing declarations apply to each property.

Where Specificity Fits in the Cascade

The browser does not start with specificity. It first filters declarations for relevance: selectors must match, stylesheets must be active, and media, container, support, or scope conditions must apply. It then compares origin and importance, encapsulation context, and cascade layers. Only declarations that remain tied reach specificity comparison.

This ordering explains why a low-specificity rule in a higher normal layer can beat an ID selector in a lower layer, and why a simple important declaration can beat a highly specific normal declaration. Specificity is powerful within its lane, but it cannot cross a stronger cascade boundary.

The Modern A-B-C Model

The current model counts three categories. A is the number of ID selectors. B is the total number of class selectors, attribute selectors, and pseudo-classes. C is the total number of type selectors and pseudo-elements. The universal selector and combinators contribute nothing.

ColumnWhat it countsExamples
AID selectors#header, #checkout
BClasses, attributes, pseudo-classes.card, [disabled], :focus
CType selectors and pseudo-elementsbutton, ::before

A selector such as #app .card button:hover::before has specificity 1-2-2: one ID; one class plus one pseudo-class; and one type plus one pseudo-element.

The Older Four-Part Teaching Model

Many tutorials write specificity as inline-ID-class-type, producing values such as 1-0-0-0 for an inline style. This can be a useful memory aid for normal author styles, but current selector specifications calculate selector specificity with three components. A style attribute is not a selector and receives special author-origin precedence in the cascade.

Use the three-column model when calculating selectors. Treat normal inline declarations separately: they outrank normal author declarations from stylesheets. Important declarations, layers, origins, animations, and transitions must still be resolved through the broader cascade rather than by inventing larger specificity numbers.

Compare Columns from Left to Right

Specificity tuples are compared lexicographically. First compare A. If one selector has more IDs, it wins regardless of the values in B and C. If A ties, compare B. Only when A and B tie does C matter. This is not base-ten arithmetic and the columns do not carry into one another.

.a.b.c.d.e.f.eight.more.classes  /* 0-9-0 */
#one-id                         /* 1-0-0 */

The ID selector wins because 1-0-0 outranks 0-9-0. Ten, one hundred, or more class selectors never convert into one ID point.

Rule One: Count ID Selectors

Every ID selector contributes one to A. #account has 1-0-0, while #account #settings has 2-0-0. Multiple IDs in one selector may be syntactically valid even when the selector cannot match ordinary HTML because an element cannot have two different ID values.

ID selectors create strong coupling and are difficult to override within the same layer. IDs remain useful for fragments, labels, ARIA relationships, and script identity, but reusable visual components usually benefit from classes and flatter specificity.

ID Selectors Versus ID Attribute Selectors

The syntax determines the specificity category. #header is an ID selector and contributes 1-0-0. [id="header"] is an attribute selector and contributes 0-1-0, even though both can match the same element.

Some legacy overrides use the attribute form to target an ID without adding ID-level weight. That technique is valid but should be documented, because future readers may assume the forms are equivalent in specificity. A component class is often clearer.

Rule Two: Count Class Selectors

Each class selector adds one to B. .card has 0-1-0. .card.featured.compact has 0-3-0. The classes can appear on the same element or across descendants; location does not change their contribution.

Classes are widely used for components because they are reusable, explicit, and moderately weighted. Keep them role-based and stable. Adding extra ancestor classes solely to win a conflict may fix one case while increasing the override cost for every later variant.

Rule Three: Count Attribute Selectors

Every attribute selector contributes one to B, whether it checks existence, exact value, token membership, prefix, suffix, substring, language, or case behavior. [disabled], [type="email"], and [data-state~="open"] each contribute 0-1-0.

In input[type="email"][required], the two attributes contribute two B points and the type contributes one C point, producing 0-2-1. Attribute complexity does not create extra points inside one attribute selector.

Rule Four: Count Ordinary Pseudo-Classes

Most pseudo-classes count like classes, adding one to B. Examples include :hover, :focus-visible, :checked, :disabled, :first-child, :empty, :root, and :scope. Their behavioral meaning does not change their weight.

button.primary:hover has 0-2-1: one class, one pseudo-class, and one type selector. Functional pseudo-classes that evaluate selector lists have special rules described later and should not be counted mechanically as one B point.

Rule Five: Count Type Selectors

Every type selector contributes one to C. p has 0-0-1, and article section p has 0-0-3. Namespace-qualified types still represent type selectors rather than extra categories.

A class selector such as .message outranks any chain containing only type selectors because B is compared before C. Long element paths therefore create markup coupling without gaining class-level priority.

Rule Six: Count Pseudo-Elements

Pseudo-elements contribute one to C. ::before, ::after, ::marker, ::first-line, and ::selection are common examples. p::before has 0-0-2: one type and one pseudo-element.

Some legacy single-colon spellings such as :before remain accepted for compatibility, but they still behave as pseudo-elements for specificity. Prefer double-colon syntax so the distinction from pseudo-classes is clear.

Rule Seven: Ignore the Universal Selector

The universal selector * contributes 0-0-0. It can narrow a relationship or make intent explicit without increasing specificity. .stack > * therefore has 0-1-0: the class counts, while the child combinator and universal selector do not.

Zero specificity does not mean a selector never wins. If competing declarations have equal higher cascade dimensions and the other selector also has zero specificity, source order can decide. A universal rule can also supply a value when no competing declaration applies.

Rule Eight: Ignore Combinators

Descendant spaces, child combinators (>), adjacent siblings (+), general siblings (~), and column combinators do not add specificity. Only the simple selectors around them count. article > section + p has 0-0-3.

Combinators can still make a selector fragile by tying it to structure. Their zero weight should not be interpreted as zero maintenance cost. Use a relationship only when that relationship is part of the component contract.

Compound and Complex Selectors

A compound selector combines simple selectors without a combinator, such as button.primary[aria-pressed="true"]:hover. A complex selector joins compound selectors with combinators, such as .toolbar > button.primary. Specificity adds all contributing simple selectors across the entire selector.

For .toolbar > button.primary[disabled], B equals three for toolbar, primary, and disabled; C equals one for button. The result is 0-3-1. The child combinator adds nothing.

Selector Lists Are Calculated Separately

In an ordinary comma-separated selector list, each selector keeps its own specificity. The values are not added together, and the strongest selector does not automatically strengthen its neighbors. In h1, .title, #page-title, the three specificities are 0-0-1, 0-1-0, and 1-0-0.

If an element matches more than one selector in the list, the matching process uses the most specific matching selector for that rule. Do not read a selector list as one combined score.

The :is() Specificity Rule

The :is() pseudo-class itself adds no pseudo-class point. Its specificity is replaced by the specificity of the most specific complex selector in its argument list. The strongest argument determines the weight even when another argument is responsible for the practical match.

:is(.card, #featured) .title
/* strongest argument: #featured = 1-0-0
   plus .title = 0-1-0
   total = 1-1-0 */

Keep arguments similar in weight. Adding an ID to a convenient grouping can unexpectedly strengthen every use of the rule.

The :not() Specificity Rule

Like :is(), :not() contributes the specificity of its most specific argument rather than one ordinary pseudo-class point. p:not(.intro) has 0-1-1. :not(.card, #modal) has 1-0-0 because the ID is the strongest argument.

Negation can increase weight even though the excluded selector does not match the target. Use :not() for small, natural exception sets. A long list of strong exclusions is hard to read and often better represented by a positive component role.

The :has() Specificity Rule

The relational pseudo-class :has() also adopts the strongest complex selector in its argument list. In .card:has(> img.hero, #featured-media), the ID argument supplies 1-0-0, and the outer class adds 0-1-0, producing 1-1-0.

Relative combinators inside the argument add no weight, but the selectors they connect do. Scope :has() to stable components and avoid placing IDs in mixed argument lists unless the stronger specificity is intentional.

The :where() Specificity Rule

:where() behaves like :is() for matching, but its entire contribution is always 0-0-0. Neither the pseudo-class nor any selector inside it contributes. This makes it valuable for defaults and contextual rules that should remain easy to override.

:where(#app, .article, main) h2 {
  margin-block-start: 2rem;
}
/* specificity: 0-0-1 from h2 only */

Selectors outside :where() still count. Layers and origins still precede specificity, so zero weight does not guarantee that a declaration always loses.

:is() Versus :where()

Both pseudo-classes can reduce repeated selector text, but they express different cascade intent. Use :is() when grouped alternatives should carry the strongest argument's weight. Use :where() when context should filter matches without making the declaration harder to override.

A reset might use :where(ul, ol) for zero-weight defaults. A component relationship might use .prose :is(h2, h3, h4), whose strongest argument is one type point, plus the prose class outside. Choose based on desired priority, not syntax length alone.

:nth-child() and :nth-last-child()

Without an of selector list, :nth-child() and :nth-last-child() are ordinary pseudo-classes and add one B point. With of S, they add that pseudo-class point plus the specificity of the strongest complex selector in S.

:nth-child(even of li, .item)
/* :nth-child = 0-1-0
   strongest argument .item = 0-1-0
   total = 0-2-0 */

The arithmetic expression such as 2n+1 contributes nothing. Only the pseudo-class and optional selector list matter.

:scope and @scope

The :scope pseudo-class counts as 0-1-0 because it is an ordinary pseudo-class. The boundaries declared by @scope, however, do not automatically add specificity to selectors inside the scope. Scoped rules keep the specificity of their own selectors.

When competing rules tie on specificity and earlier cascade dimensions, scoping proximity can choose the rule whose scope root is closest to the matched element. Proximity is a separate tie-breaker; it should not be added to the A-B-C tuple.

Native CSS Nesting Specificity

In native CSS nesting, the nesting selector & takes the largest specificity among the complex selectors in the parent rule's selector list, similar to :is(). The nested selector then adds any selectors written around or after &.

#app, .shell {
  & .title { color: navy; }
}
/* & takes 1-0-0 from #app
   .title adds 0-1-0
   total = 1-1-0 */

This may be stronger than manually expanding only the branch that matched. Avoid mixing IDs and low-weight selectors in a parent list when nested rules should remain easy to override.

Nesting Without an Explicit &

A nested selector that starts with a type, class, or other non-combinator is implicitly relative to the parent. Its effective selector includes the nesting context, so parent specificity still contributes. A nested selector beginning with a combinator also includes the implied nesting selector.

Native nesting is not string concatenation like some preprocessor features. Inspect the equivalent selector model and generated browser rules. Deep nesting can accumulate specificity and structural coupling even when the source appears visually organized.

Inline Styles and Specificity

A style attribute is not a selector, so it does not receive an A-B-C selector tuple in the modern calculation. Normal inline declarations are placed after normal author stylesheet declarations and therefore outrank them within the author origin. The common 1-0-0-0 notation is a teaching shorthand for this special precedence.

An important stylesheet declaration can beat a normal inline declaration because importance is considered earlier. An important inline declaration outranks other important author declarations, while important user and user-agent origins and active transitions can still be stronger.

!important Is Not Specificity

!important moves one declaration into an important cascade category; it does not add points to the selector. A low-specificity important rule beats a normal rule with many IDs because importance is compared first. When both declarations are important in the same origin and layer context, specificity can again decide.

Do not represent important as a fifth specificity column. Keeping it separate prevents incorrect calculations and encourages developers to inspect origins and layers before escalating selectors.

Cascade Layers Come Before Specificity

Within an origin, normal declarations in later layers outrank normal declarations in earlier layers, regardless of selector weight. Unlayered normal declarations outrank named-layer normal declarations. Important declarations reverse layer order. Specificity is compared only among declarations in the same applicable layer priority.

@layer base, components;

@layer base {
  #checkout button { color: gray; }
}

@layer components {
  .pay-button { color: white; }
}

The component class wins for normal declarations even though the base selector contains an ID. Layers prevent specificity from carrying the entire architecture.

Origins Come Before Specificity

Normal author declarations outrank normal user and normal user-agent declarations. Important user declarations outrank important author declarations. A selector from a lower origin category cannot cross that boundary by accumulating more IDs, classes, or types.

This is especially important for accessibility. An author's highly specific important rule must not defeat an important user preference. Specificity operates after origin and importance have protected that balance.

Animations and Transitions

Active keyframe animation values outrank all normal declarations but remain below important declarations. Active transition values occupy the highest cascade category while interpolation is occurring. Neither relationship is explained by selector specificity.

If DevTools shows an apparently winning selector but the visual value differs, inspect animations and transitions. After the effect ends or stops supplying the property, the underlying cascaded declaration becomes visible again.

Inheritance Is Not a Specificity Contest

An inherited value from a parent is used only when the child has no direct winning value for an inherited property, or when inheritance is explicitly requested. A low-specificity rule targeting the child beats a highly specific declaration whose value merely flows from an ancestor.

For example, #app { color: navy; } on a parent does not beat p { color: crimson; } on its child paragraph. The two selectors do not compete for the same element. Inspect the child before increasing ancestor specificity.

Worked Example: Basic Selectors

p                    /* 0-0-1 */
.note                /* 0-1-0 */
#intro               /* 1-0-0 */
article .note p      /* 0-1-2 */
#main .note p        /* 1-1-1 */

The tuple records counts, not visual importance. Among declarations in the same cascade context, #main .note p outranks the others because it has an ID and then additional B and C contributions. #intro beats every selector without an ID, even though its B and C columns are zero.

Keep calculations grouped by column. Writing them beside complicated selectors during review makes hidden escalation easier to notice.

Worked Example: Form Control

form .field input[type="email"]:required:focus-visible

This selector has 0-4-2. The field class, type attribute, required pseudo-class, and focus-visible pseudo-class produce four B points. The form and input types produce two C points. The descendant combinators contribute nothing.

The selector is understandable but may be stronger than necessary. A component class on the control and state pseudo-classes might express ownership with fewer structural assumptions. Calculation helps evaluate maintainability, not merely predict the winner.

Worked Example: Button Decoration

button.primary:hover::before

The result is 0-2-2. Primary and hover contribute two B points; button and before contribute two C points. If .action::before competes in the same cascade context, its 0-1-1 loses.

If the lower-weight declaration belongs to a higher normal layer, it can still win because layers are resolved first. Always state the assumed cascade context when comparing specificity examples.

Worked Example: Relational Selector

.field:has(input:invalid, #forced-error) .message

The strongest :has() argument is #forced-error at 1-0-0. The outer field and message classes add two B points. The final specificity is 1-2-0. The input:invalid branch does not add to the ID branch; only the strongest argument is adopted.

This example shows why mixing an ID into a functional selector list can surprise maintainers. Separate exceptional cases when they need different priority.

Worked Example: Zero-Weight Context

:where(#app, .dashboard, main) .card h2

The context inside :where() contributes zero. The card class contributes one B point, and the heading contributes one C point, producing 0-1-1. The ID inside :where() does not raise the score.

This pattern can scope base component rules to an application region without making them difficult to override. It remains important to avoid broad matching that accidentally reaches nested independent components.

Equal Specificity and Source Order

When specificity ties and no closer scope resolves the contest, the later declaration in document order wins. Two occurrences of .message both have 0-1-0, so the later one controls shared properties. Independently linked stylesheets are ordered according to their document linking order.

Source order should resolve intentional variants, not accidental imports. Runtime CSS injection and bundling can change final order, so inspect production output when equal-weight rules behave differently from development.

Repeated Selectors Increase Specificity

Duplicating a simple selector counts each occurrence. .button.button has 0-2-0, and #app#app has 2-0-0. This technique can override otherwise unchangeable legacy CSS without adding a new ancestor, but it is rarely a good default.

If repetition is unavoidable, isolate and comment the workaround. Prefer changing layer order, correcting the original selector, or exposing a stable component API. Artificial weight creates technical debt even when the calculation is valid.

Specificity Cheat Sheet

SelectorSpecificityReason
*0-0-0Universal selector is ignored
p0-0-1One type selector
::before0-0-1One pseudo-element
.card0-1-0One class
[open]0-1-0One attribute selector
:hover0-1-0One pseudo-class
#header1-0-0One ID selector
:where(#header, .card)0-0-0Where always contributes zero
:is(.card, #header)1-0-0Strongest argument is an ID

Common Mistake: Adding the Columns

Specificity is not a decimal total. A selector with many type or class points cannot carry into a stronger column. Compare A first, then B, then C. Converting 0-12-3 into 123 and 1-0-0 into 100 produces the wrong result.

Keep the tuple notation intact in documentation and code reviews. Its structure communicates the actual comparison algorithm.

Common Mistake: Counting Combinators

Spaces, greater-than signs, plus signs, and tildes express relationships but contribute no specificity. In nav > ul + ul a, only nav, two ul types, and a count, producing 0-0-4.

A structural selector can still become hard to maintain even with a low score. Specificity and coupling are separate quality concerns.

Common Mistake: Treating Every Pseudo-Class Alike

Ordinary pseudo-classes add one B point, but :is(), :not(), and :has() adopt the strongest argument; :where() contributes zero; and :nth-child(... of S) adds its own point plus the strongest selector in S. Mechanical counting produces incorrect values.

Identify the pseudo-class category before calculating. Functional selector-list pseudo-classes deserve explicit review in shared components.

Common Mistake: Assuming the Matched :is() Branch Sets Weight

The specificity of :is(), :not(), and :has() is based on the most specific argument in the list, not merely the branch that happened to establish the match. This gives each selector a stable specificity for efficient matching.

A distant ID argument can therefore strengthen ordinary matches. Group alternatives with similar weight or split rules when their intended priority differs.

Common Mistake: Overusing IDs

ID selectors make same-layer author overrides expensive. A reusable component placed under an ID-scoped page selector may require another ID or a stronger cascade category to customize. That pressure spreads high specificity through the project.

Use classes for reusable visual roles and reserve IDs for identity, fragments, labels, and relationships. If a legacy page uses ID scoping, migrate incrementally with layers and stable component hooks.

Common Mistake: Solving Every Conflict with !important

Important does not repair specificity architecture; it moves the declaration to another cascade category. Once many rules become important, their selectors compete by specificity again, creating a more difficult version of the original problem.

Inspect origin, layer, selector scope, and source order first. Use important only through a documented policy for genuine invariants or controlled integration boundaries.

Designing Low-Specificity CSS

Use one stable component class for base presentation and an additional class, attribute, or native pseudo-class for variants and state. Keep global defaults light, often with type selectors or :where(). Use cascade layers to arrange reset, vendor, base, components, utilities, and overrides.

Low specificity is not a goal by itself. Selectors must still express ownership and avoid leaking into nested components. Predictable, narrow, and intentionally layered rules are more valuable than blindly forcing every selector to 0-1-0.

Specificity Budgets and Linting

Teams can configure Stylelint or custom checks to limit maximum specificity, nesting depth, IDs, duplicate selectors, and unapproved important declarations. A budget turns architecture into an automated signal and catches accidental escalation during review.

Allow documented exceptions for rich text, third-party integrations, and legacy migration. Linting cannot determine semantic ownership, so combine it with code review, visual tests, and accessibility testing.

Debugging Specificity with DevTools

Inspect the element and property rather than guessing. The Styles panel shows matching selectors, crossed-out declarations, source files, inline values, inherited rules, and often cascade-layer information. The Computed panel identifies the final value and its source.

Confirm relevance, origin, importance, layer, animation, and transition status before comparing A-B-C. Then calculate the actual matching selector, including functional pseudo-class and nesting behavior. If specificity ties, examine scope proximity and source order.

A Practical Calculation Workflow

First isolate one complete selector, not an entire comma-separated list. Expand the logic mentally without double-counting grouped arguments. Count IDs into A. Count classes, attributes, and ordinary pseudo-classes into B. Count types and pseudo-elements into C. Ignore universal selectors and combinators.

Then apply special rules for :is(), :not(), :has(), :where(), :nth-child(), and nesting. Compare tuples from left to right only after confirming the declarations share the same earlier cascade priority.

Specificity Interview Questions

What is specificity? It is the selector weight used to compare otherwise equal cascade declarations. What does A-B-C count? IDs; classes, attributes, and pseudo-classes; then types and pseudo-elements. Do combinators count? No. Does the universal selector count? No.

How does :where() behave? It always contributes zero. How do :is(), :not(), and :has() behave? They adopt the strongest argument. Is inline style a selector score? No; it has special author cascade precedence. What happens after a tie? Scope proximity and then source order can decide.

Practical Specificity Checklist

  • Confirm that competing declarations share the same earlier cascade priority.
  • Use the modern three-column A-B-C selector model.
  • Count IDs, then class-level selectors, then type-level selectors.
  • Ignore universal selectors and combinators.
  • Apply the special rules for functional pseudo-classes and nesting.
  • Keep inline styles and important declarations outside the selector score.
  • Compare columns from left to right without carrying values.
  • Use layers and stable component hooks instead of specificity escalation.
  • Inspect the actual matching selector in browser DevTools.
  • Lint, review, and test selectors as the codebase evolves.

Key Takeaway

CSS specificity is a structured comparison, not a vague measure of how detailed a selector looks. Count IDs in A; classes, attributes, and pseudo-classes in B; and types and pseudo-elements in C. Compare the columns from left to right. Ignore combinators and the universal selector, and apply the defined exceptions for functional pseudo-classes, :where(), child-index selectors, and nesting.

Always place specificity inside the larger cascade. Origins, importance, encapsulation context, layers, animations, and transitions can decide before selector weight is considered; scope and source order can decide after a tie. With low, predictable selectors and an intentional layer strategy, specificity becomes a precise debugging tool rather than an escalating contest.