DEV Community

Brian Bauernschmitt
Brian Bauernschmitt

Posted on Originally published at brokeinprod.dev

Tailwind Typography's prose Is Leaking Into Your Components — and the Three-Tier Fix

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 the prose class, forty lines up in the code.

Start here: The prose class from @tailwindcss/typography styles 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 in not-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>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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


Originally published at brokeinprod.dev — field notes from production: the error, the cause, the one-line fix.

Top comments (0)