LeaVerou/postcss-zero-specificity

★ 0Forks 0JavaScriptGitHub ↗Compare

README

postcss-zero-specificity

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-specificity
import 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

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.

Details

  • Each selector of a list is wrapped separately: a, .b becomes :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 @keyframes step names, which aren't selectors.
  • @scope prelude 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 :host inside :is()/:where() has historically been inconsistent, and :host() arguments keep their specificity regardless.

Composing with other transforms

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],
});

Contributors

LeaVerou

Issues