My CTA button had underlined links and the wrong text color, and no matter which utility class I stacked on it the issues didn't correct. The culprit wasn't the button.
It was theproseclass, forty lines up in the code.Start here: The
proseclass from@tailwindcss/typographystyles every matching element beneath it — headings, paragraphs, and yes, the<a>inside your custom button component rendered mid-article. That's its job: it's built for HTML that you don't control and applied to a tree you partially do. I have three fixes, in order of preference: (1) design your in-article components to match prose's expectations so there's no fight; (2) re-tune prose itself with element modifiers (prose-a:…) on the container; (3) wrap genuinely custom islands innot-prose— the sledgehammer, with caveats.
The setup
An article layout on a content site: MDX renders inside a wrapper carrying prose from @tailwindcss/typography — on this build, exactly this:
<div className="prose prose-stone max-w-none font-serif">
<MDXRemote source={article.content} … />
</div>
Which is the right call — it's the fastest way to make Markdown-sourced body text beautiful. However, articles aren't only Markdown. This build renders custom components inside the article body: styled CTA links, product cards, callouts.
The components came out wrong: link colors overridden toward prose's palette, underlines appearing on things designed not to have them, spacing around the components inheriting article rhythm.
The symptom
The confusing part is where you look. The component's own classes are correct — you can read them right there in the JSX. However, rendered inside the article, the element picks up styling that the component never asked for. The plugin's own issue tracker has this exact report: a button rendered inside prose content, underlined, resisting utility overrides.
The trap: you debug the component, but the cause is the context — the same debugging shape as the fs is not defined bug: correct code, wrong place. prose works by descendant selectors — .prose generates rules targeting the elements beneath it (a, h2, ul, img, …).
Any matching tag in the subtree is in scope, and your component's markup is, structurally, just more tags in that subtree. prose cannot tell your hand-built CTA from a Markdown link. This is NOT a bug, but rather it's the feature, meeting markup it wasn't warned about.
The diagnosis
The plugin is designed for "HTML that you don't control." The moment you embed components that you do control inside it, two styling authorities claim the same elements. Now, which one wins any given property is a cascade question (the modern plugin deliberately keeps specificity low via :where()), but the practical result is consistent: properties your component doesn't
explicitly set get prose's values, and the fight over the ones it does set is decided by CSS order — a place you don't want load-bearing logic.
So the fix isn't "win the specificity war." It is deciding, deliberately, which styling authority owns what.
The fix — three tiers, in order
Tier 1 — match prose's expectations (the fix that isn't a fight).
For components that are content — inline CTAs, emphasized links — the cleanest move is to design them to cooperate: let them inherit prose's link color and underline, but differentiate with weight or an accent border instead.
This build runs Tier 1 on both of its content-like components: (1) the inline affiliate link is deliberately bold+underlined in the article's accent, and (2) the CTA button settled on light background, dark text (text-stone-900), and an accent border — colors with which prose has no quarrel.
The idea is to swim with the current, not against it. Zero CSS conflict, and the components read as native to the article — which is what readers trust anyway.
Tier 2 — re-tune prose itself with element modifiers.
When you want prose's rules changed everywhere — say, links in your brand color, no underline until hover — set it on the container, once:
<article class="prose prose-a:text-emerald-700 prose-a:no-underline hover:prose-a:underline">
This is the supported customization channel: you're not fighting with the plugin, but rather you're configuring it. Good for house-style adjustments; however, it's the wrong tool for "this one component is special."
Tier 3 — not-prose for genuine islands.
For embedded UI that is not content — a product comparison card, an interactive demo, a form — sandbox it:
<div class="not-prose">
<ProductCard … />
</div>
Prose styles stop at the boundary, which means that the island styles itself from zero. Two documented caveats: (1) you can't nest a new prose region inside a not-prose block, and (2) the island now gets none of the typographic defaults — every margin and font size inside is your job again. It's the right tool for cards and widgets; it's overkill (and extra work) for a styled link.
The rule of thumb
Content-like → Tier 1 (cooperate). House-wide preference → Tier 2
(configure). True UI island → Tier 3 (sandbox). If you find yourself stacking !important-flavored utilities on one element inside an article, you've skipped a tier.
I'll confess how I know the fourth option is worse: I took it.
Late one evening, I was tired of the cascade fight, so I ripped
the typography plugin > out of the build entirely. The buttons
looked right — but every heading, paragraph, and list in the
article went back to unstyled browser defaults, because that
plugin had been quietly doing all of that work. I restored it the
next morning and picked a tier instead. Rebuilding Markdown's
entire typography by hand to win one button is not a trade; it's
a tantrum.
prose styles everything under it because that's its job. The fix
was never beating it — it was deciding which elements are content, which are chrome, and telling each side its jurisdiction.
FAQ
Why don't my utility classes just win — utilities beat plugins, right?
Not reliably here. The plugin keeps its selectors low-specificity on purpose, so outcomes fall to source order and which properties each side sets. Some overrides work, some don't, and it feels random — which is why the answer is jurisdiction (the three tiers), not escalation.
Can I put not-prose on the component itself instead of a wrapper?
Yes — it's a class like any other. However, a wrapper div is just often cleaner in MDX, and remember the exclusion applies to the element and everything inside it.
Does this happen with prose alternatives / my own .markdown styles?
Identically. Any descendant-selector typography system has the same
jurisdiction problem with embedded components; the three tiers apply regardless of whose plugin generated the rules.
Further reading
-
@tailwindcss/typography — README (element modifiers,
not-prose, the no-nesting caveat): https://github.com/tailwindlabs/tailwindcss-typography - Tailwind — Typography v0.5 announcement (why
not-proseexists, the specificity design): https://tailwindcss.com/blog/tailwindcss-typography-v0-5 - The original issue — a button inside prose, underlined: https://github.com/tailwindlabs/tailwindcss-typography/issues/32
Originally published at brokeinprod.dev — field notes from production: the error, the cause, the one-line fix.
Top comments (0)