I was injecting a chunk of HTML into a .prose-dark typography wrapper on an editorial site I'd ported from React to WordPress. One of the headings in that HTML needed a different color than the default, so I reached for a utility class the way I always do: <h4 class="text-white">. Nothing happened. The heading kept the muted gray from prose, as if my text-white class had never existed. No console error, no build warning — the heading just quietly refused the color I asked for.
Here's roughly what I'd written:
<div class="prose-dark">
<h4 class="text-white">A heading that should be white</h4>
</div>And here's the prose rule already sitting in the stylesheet, the one that colors every heading inside the wrapper:
.prose-dark h4 {
color: #9ca3af; /* gray, and it's the one that wins */
}DevTools made it obvious: color: #fff from .text-white showed up with a strikethrough. The browser was crossing it out on purpose. The prose rule won, not my utility.
Why this happens
This is pure specificity — not source order, not !important, not cascade layers. Let's count the two selectors fighting over the same h4 color.
The selector .text-white is just one class. Its specificity is (0,1,0) — one class, zero elements.
The selector .prose-dark h4 has one class (.prose-dark) plus one type selector (h4). Its specificity is (0,1,1) — one class, one element.
Compare them column by column from the left: IDs tie at zero, class counts tie at one, then in the type column 1 beats 0. So (0,1,1) outranks (0,1,0), and .prose-dark h4 wins outright. That innocent-looking h4 type selector is exactly what adds the extra point of specificity, and that one point is enough to bury my utility class.
What makes this trap sneaky is the intuition that a utility class "should be stronger" than baseline typography. But the baseline targets elements through a type selector inside a class scope, and that combination is automatically more specific than any lone class. As long as the typography wrapper writes its rules as .wrapper h4, .wrapper p, .wrapper a, it builds a specificity wall of (0,1,1) around every element. A single utility class at (0,1,0) can never climb over that wall. My override wasn't a typo — it lost on the math.
The fix
The answer isn't to crank up my override's specificity (that's an arms race with no finish line) — it's to drop the prose rule's specificity to zero. CSS has the exact tool for this: the :where() pseudo-class. Anything wrapped in :where() contributes zero specificity while still matching the same elements.
I wrapped the type selector in the prose rule with :where():
.prose-dark :where(h4) {
color: #9ca3af;
}Now count again. .prose-dark is still one class, but :where(h4) inside it contributes zero. Because :where() genuinely strips out its argument's contribution, that prose baseline now behaves like a rule that's trivially easy to override. My .text-white utility at (0,1,0) wins through cascade order — and when I need a guarantee, any single class on the element is now enough to beat the default.
<div class="prose-dark">
<h4 class="text-white">Actually white now</h4>
</div>Nothing else changed. No !important, no extra selector, no inline style. Just wrapping the type selector inside the prose rule in :where(), and the heading immediately took the color from my utility class.
The fun part: this is exactly how Tailwind's own Typography plugin keeps itself overridable. Its default rules are authored with :where() precisely so that any utility class you slap on an element inside .prose always wins. I'd simply hand-rolled my own prose wrapper and forgotten to copy that trick — so the specificity wall went right back up.
The takeaway
Typography wrappers that style elements through type selectors — .prose h4, .prose p, .prose a — quietly build a (0,1,1) specificity wall that blocks consumer overrides. The moment someone tries to drop a single utility class onto an element inside, that override loses without a sound, without an error, and makes debugging feel like chasing a ghost.
If you're writing a styling system other people will use — or future-you will use — wrap its default selectors in :where() so they contribute zero specificity. That way a single class is always enough to override the defaults, and nobody has to start an !important war just to change a heading color. Defaults are allowed to have opinions about how things look; they just shouldn't be more stubborn than the consumer's intent.
