Keeping Specificity Flat When BEM Meets Modern Selectors

BEM is one of the more widely accepted CSS methodologies, and for good reason: when used properly, every selector in a codebase carries the same specificity score of 0,1,0. That consistency is what makes the Cascade predictable on large projects, where a single style change shouldn't silently bleed into an unrelated component.

The arrival of modern relational selectors — :is(), :where(), :not(), and eventually :has() — complicates that tidy picture. These tools are powerful, but they bring their own specificity rules that can quietly break the flat structure BEM provides.

The Specificity Profiles of Modern Selectors

All modern browsers support :is(), :where(), and :not(). They look similar, but their effect on specificity differs significantly.

  • :where() always contributes 0,0,0 to specificity, regardless of what's inside it.
  • :is() takes on the specificity of the most specific selector in its argument list. A list containing an ID will therefore match the specificity of that ID.

Consider what happens when you drop a pseudo-class into a standard BEM selector:

/* ❌ specificity score: 0,2,0 */
.something:not(.something--special) {
  /* styles for all somethings, except for the special somethings */
}

That's a specificity of 0,2,0 rather than the expected 0,1,0. The risk is ordering failures. In the following example, even though the second rule comes later in the source, the first rule wins because its specificity is higher:

.something:not(a) {
  color: red;
}
.something--special {
  color: blue;
}

The .something--special elements stay red because the first selector's 0,1,1 beats the later 0,1,0. On a large codebase, these small inconsistencies compound into real maintenance headaches.

Zeroing Out Specificity With :where()

The zero-specificity behavior of :where() makes it a useful escape hatch. You can write a BEM modifier without inflating the score:

/* ✅ specificity score: 0,1,0 */
.something:where(:not(.something--special)) {
  /* etc. */
}

The .something class still contributes its usual 0,1,0, while everything inside :where() adds nothing. The selector's final score stays flat.

Nesting Without Specificity Creep

Nesting selectors in Sass or plain CSS often produces specificity that drifts from the BEM baseline. A typical nested block might look like this:

.card { ... }

.card--featured {
  /* etc. */  
  .card__title { ... }
  .card__title { ... }
}

.card__title { ... }
.card__img { ... }

Here, a featured card needs different title and image styling. The nested output gives those child selectors a higher specificity score than the rest of the system. A stricter approach would be to keep everything flat:

.card { ... }
.card--featured { ... }
.card__title { ... }
.card__title--featured { ... }
.card__img { ... }
.card__img--featured { ... }

That CSS is clean, but it pushes complexity into the template. To apply the modifier to each child element, the HTML logic must conditionally add .card--featured, .card__title--featured, and .card__img--featured separately — a lot of conditional statements just to style one component variant.

The :where() selector offers a middle path: fewer modifier classes in the template, and no specificity penalty in the CSS.

.card { ... }
.card--featured { ... }

.card__title { ... }
:where(.card--featured) .card__title { ... }

.card__img { ... }
:where(.card--featured) .card__img { ... }

In Sass, the equivalent relies on trailing ampersands:

.card { ... }
.card--featured { ... }
.card__title { 
  /* etc. */ 
  :where(.card--featured) & { ... }
}
.card__img { 
  /* etc. */ 
  :where(.card--featured) & { ... }
}

Whether this approach is preferable to scattering modifier classes across child elements is a judgment call. The point is that :where() makes it an option without sacrificing a uniform specificity profile.

Dealing With HTML You Don't Control

Third-party scripts often inject markup with IDs or non-BEM naming conventions. Styling such elements directly, say with #widget, immediately introduces a specificity level that contradicts the rest of the stylesheet.

One workaround: reference a known parent class and wrap the target in :where() to neutralize its specificity.

/* ❌ specificity score: 1,0,0 */
#widget {
  /* etc. */
}

/* ✅ specificity score: 0,1,0 */
.page-wrapper :where(#widget) {
  /* etc. */
}

The approach is effective but fragile — it depends on a parent class that could be renamed or removed. A slightly more resilient alternative uses :is() to match against both a fabricated class and the always-present <body> element:

/* ✅ specificity score: 0,1,0 */
:is(.dummy-class, body) :where(#widget) {
  /* etc. */
}

Here, :is() matches the .dummy-class (which never exists) but takes its specificity from the body selector inside the same list. Because :where() wraps the entire :is(), the overall selector never exceeds 0,1,0 — the same as any BEM class.

Looking Ahead to :has()

As :has() gains full browser support — Firefox is still working on it behind a flag as of this writing — it will introduce specificity equal to its most specific argument. For projects that adopt it, wrapping it in :where() will likely be a common pattern to keep the Cascade in check.

Consistency in selector specificity makes CSS predictable, regardless of whether you follow BEM naming strictly. The modern selector toolkit gives you more ways to maintain that consistency while writing less markup.