Why I Avoid Tailwind Prose and shadcn/typeset
Published Sep 23, 2026
When styling Markdown or MDX in Tailwind projects, the common approach is using @tailwindcss/typography with the prose class, or something like shadcn/typeset.
It is a solid default for quick setups or when you just want to focus on writing without having to build and maintain custom typography components. It also makes sense when you do not control the HTML output directly, like rendering raw HTML from an external CMS, user comments, or strings from dangerouslySetInnerHTML.
When you embed interactive React components into MDX, relying on a global prose wrapper comes with several trade-offs.
The Issues with Descendant Selectors
prose works via descendant selectors. It generates rules targeting standard tags beneath it (a, p, h2, ul, img). It cannot distinguish between a Markdown link and an <a> inside your custom CTA button, product card, or callout.
When you run into this, the usual suggestions are:
- Adapting component design: Altering your custom components so they don't trigger prose styles.
- Container modifiers: Using classes like
prose-a:...on the wrapper. not-prosewrappers: Sandboxing custom components withnot-prose.
Each approach has trade-offs. Changing a component's design just to avoid styling collisions isn't ideal. Container modifiers get verbose quickly. And not-prose removes all typographic defaults inside that subtree, plus you cannot nest a prose section back inside it.
Beyond selector collisions, two other practical factors matter:
1. Style Inconsistencies
Your application already defines specific font sizes, line heights, and colors for headers, cards, and buttons. prose uses its own predefined scale, which often creates slight mismatches between your content and the rest of the site.
2. Custom Components Are Usually Needed Anyway
Styling is only part of rendering content. You often need component-level logic-like turning standard links into client-side router <Link> components for prefetching, or adding copy buttons to code blocks.
If you are already configuring custom components for links and code, wrapping everything in prose adds another layer of styles to manage without eliminating the need for custom mapping.
A Cleaner Alternative: Custom MDX Components
MDX lets you supply a custom component mapping directly. Instead of styling elements from an ancestor wrapper, you define exactly how each element should render using your existing Tailwind tokens and UI primitives.
1. Headings and Body Text
For standard typography, you apply your design system's exact font sizes, colors, and margins:
// src/components/mdx/index.tsx
import type { MDXComponents } from "mdx/types";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "#/lib/utils";
export const mdxComponents = {
h2: ({ className, ...props }: ComponentPropsWithoutRef<"h2">) => (
<h2
className={cn("mt-5 text-xl font-bold tracking-tight text-foreground first:mt-0", className)}
{...props}
/>
),
p: ({ className, ...props }: ComponentPropsWithoutRef<"p">) => (
<p
className={cn("mt-3 leading-relaxed text-muted-foreground first:mt-0", className)}
{...props}
/>
),
// (h1, h3, lists, and blockquotes follow the same pattern)
};2. Smart Links and Client-Side Routing
Instead of rendering plain <a> tags, you can inspect the href and pass internal links to your framework's router component (like TanStack Router or Next.js Link) for client navigation and prefetching, while opening external links safely in a new tab:
import { Link } from "@tanstack/react-router"; // or next/link, etc.
// inside mdxComponents:
a: ({ href = "", className, children, ...props }: ComponentPropsWithoutRef<"a">) => {
const isInternal = href.startsWith("/");
const isExternal = href.startsWith("http://") || href.startsWith("https://");
const linkClasses = cn(
"font-medium text-foreground underline decoration-zinc-300 underline-offset-3 transition-colors duration-150 hover:text-indigo-600 hover:decoration-indigo-400 dark:decoration-zinc-600 dark:hover:text-indigo-400 dark:hover:decoration-indigo-400",
className,
);
if (isInternal) {
return (
<Link to={href} className={linkClasses} {...props}>
{children}
</Link>
);
}
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={linkClasses} {...props}>
{children}
</a>
);
}
return (
<a href={href} className={linkClasses} {...props}>
{children}
</a>
);
},3. Code Blocks and UI Islands
For <pre> tags or interactive widgets, you style the container wrapper directly without fighting inherited prose margins:
pre: ({ className, ...props }: ComponentPropsWithoutRef<"pre">) => (
<div className="mt-3 w-full max-w-full overflow-hidden rounded-xl bg-zinc-950 outline-1 -outline-offset-1 outline-white/10 first:mt-0">
<pre
className={cn("w-full max-w-full overflow-x-auto font-mono text-sm scheme-dark", className)}
{...props}
/>
</div>
),The Practical Benefits
- Component isolation: Custom interactive components remain unaffected by parent typography selectors-no unexpected CSS leaks or defensive resets needed.
- First-class routing: Internal links automatically use
<Link>, giving you client-side transitions and prefetching out of the box. - Design consistency: Headings and text use the exact same color and typography tokens as the rest of the application.