PostCSS plugin that wraps every selector in :where(), reducing its specificity to zero — the full power of selectors without the specificity wars, and without needing naming conventions like BEM to keep everything flat.
npm install postcss-zero-specificityimport postcss from "postcss";
import zeroSpecificity from "postcss-zero-specificity";
postcss([zeroSpecificity()]);#nav .item a:hover {}
/* becomes */
:where(#nav .item a:hover) {}With every selector at zero specificity, the cascade reduces to source order: later rules win, and any override — a user's stylesheet, a component consumer's one-liner — actually works without specificity arms races.
Pseudo-elements are invalid inside :where(), so they stay outside — along with any pseudo-classes after them, which address the pseudo-element itself:
a:hover::before {} /* becomes */ :where(a:hover)::before {}
.tabs::part(tab):hover {} /* becomes */ :where(.tabs)::part(tab):hover {}What stays outside keeps its specificity — the pseudo-element's own, plus that of any pseudo-classes addressing it and of ::slotted()/::part() arguments. That's inherent to pseudo-elements, not a plugin limitation.
- Each selector of a list is wrapped separately:
a, .bbecomes:where(a), :where(.b). - Selectors that are already zero-specificity (
*, a lone:where()) are left alone, and the output is idempotent. &goes inside the wrapper (&:hover→:where(&:hover), bare&→:where(&)) — usually a no-op specificity-wise since parents get wrapped too, but load-bearing where&carries specificity of its own, like inside@scope.- Selectors are transformed everywhere they appear — inside
@media,@supports,@layer,@container,@scope, and any at-rule the plugin has never heard of — except@keyframesstep names, which aren't selectors. @scopeprelude selectors contribute no specificity to scoped rules, so they are left untouched.- Shadow DOM caveat: selectors built around
:host/:host()are best excluded — browser support for:hostinside:is()/:where()has historically been inconsistent, and:host()arguments keep their specificity regardless.
This plugin is built on postcss-selector-transform, which parses each rule's selector once and runs any number of transforms over the shared AST. The underlying transform is exported as zeroTransform, so it can share a parse with other transforms:
import selectorTransform from "postcss-selector-transform";
import { zeroTransform } from "postcss-zero-specificity";
selectorTransform({
transforms: [myOtherTransform, zeroTransform],
});