ikke.tokyo logo

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:

  1. Adapting component design: Altering your custom components so they don't trigger prose styles.
  2. Container modifiers: Using classes like prose-a:... on the wrapper.
  3. not-prose wrappers: Sandboxing custom components with not-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.