shadcn/typeset vs Tailwind Typography: Styling Markdown

shadcn/typeset vs Tailwind Typography: Styling Markdown

1 2 15
calendar_today agoschedule7 min read
— Originally published at morello.dev

You render some markdown, get back a pile of unstyled HTML, and then you style it. Headings, paragraphs, lists, tables, code. You do it for the blog. Then you do it again for the docs. Then a third time for the chat UI that streams model output token by token. Every context, the same elements, slightly different rules.

shadcn shipped a take on this on July 10, 2026, called shadcn/typeset. It's worth a look even if you don't use the rest of shadcn/ui, because it isn't a component and it isn't a CLI install. It's one CSS file you copy into your project and own outright.

What is shadcn/typeset?

Typeset is a styling system for rendered HTML and markdown, delivered as a single CSS file. You wrap your content in a .typeset container and everything inside it gets styled: headings, paragraphs, lists, tables, code, blockquotes, links, footnotes, even MathML and <details>.

<div className="typeset typeset-docs">
  <YourMarkdownRenderer>{content}</YourMarkdownRenderer>
</div>

.typeset turns the styles on. .typeset-docs is a preset, a small class you layer on top to tune the look for a specific context. You can define as many presets as you have contexts: one for the blog, one for the docs, one for chat.

The thing to internalize up front: there's no npm package and no config layer. The changelog puts it plainly: "one CSS file that styles everything inside a typeset container. The file lives in your project, so you can change it directly when you need to." If a heading annoys you, you open the file and change the rule. That's the whole distribution model.

This is a different thing from shadcn/ui's Typography component, which gives you styled React heading and paragraph components to author by hand. Typeset styles HTML you didn't write, the output of a markdown renderer.

How do you install shadcn/typeset?

You don't run a CLI. As of this release there's no npx shadcn@latest add typeset, and expecting one will send you looking for a command that doesn't exist.

Instead you build the file in the interactive builder, pick your sizes and fonts, and copy the generated typeset.css into your project. Then you import it after Tailwind:

@import "tailwindcss";
@import "./typeset.css";

Import order matters, and I'll come back to why in a moment. Once it's in, the wrapper from the first snippet is all you need.

Size, leading, and flow

Typeset condenses typographic tuning into three CSS variables it calls "rhythm," and everything else derives from them:

.typeset {
  --typeset-size: 1em;      /* base font size */
  --typeset-leading: 1.75;  /* line-height */
  --typeset-flow: 1.25em;   /* vertical space between blocks */
}

A preset is just those three variables set to different values under a class name:

.typeset-chat {
  --typeset-flow: 1em;
  --typeset-leading: 1.6;
}
.typeset-docs {
  --typeset-size: 15px;
  --typeset-flow: 1.5em;
}

Because it's all CSS custom properties, you can override a single value inline without touching the file:

<article className="typeset [--typeset-flow:1.75em]">{content}</article>

The sizing is container-relative rather than a fixed rem scale. Below 48rem the base size gets a 1.125x bump and then settles back to the raw value on larger screens, so text reads comfortably on a phone without a separate small-screen variant. Colors and borders pull from your app's theme tokens (--color-foreground, --color-muted-foreground, --color-border, --radius) with sensible fallbacks, so dark mode just works when your tokens flip. There's no second inverted palette to maintain.

One deliberate omission: Typeset doesn't set a max-width. Your layout owns the measure. The builder's Measure control adds a max-width to the wrapper if you want it, but the stylesheet itself stays out of your layout's business.

Why your Tailwind utilities win without !important

Here's the detail I liked most. Typeset lives in the CSS @components layer and uses zero-specificity :where() selectors for every element rule:

@layer components {
  .typeset {
    & :where(h1) { font-size: 1.75em; /* ... */ }
    & :where(p)  { margin-block-start: var(--typeset-flow); }
  }
}

The override comes from cascade layers, not specificity. Typeset's rules live in the @components layer, and Tailwind's utilities live in the @utilities layer, which is declared after it. When rules in two different layers conflict, the later layer wins outright, no matter how specific either selector is. So a utility beats a Typeset rule with no !important and no special modifier syntax. Slap .text-3xl on a heading and it just wins.

:where() does a related but separate job: it holds every element rule at zero specificity, so an override that isn't already shielded by a later layer, like plain CSS you write yourself or a rule in the same layer, can still win without a specificity fight. That's also why import order matters. Importing Tailwind first establishes its layer order, which puts @utilities after Typeset's @components.

If you want to pull a whole subtree out, there's an escape hatch: .not-typeset (or the data-not-typeset attribute), which cascades to descendants including any nested .typeset container.

<Card className="not-typeset">Untouched component.</Card>

shadcn is candid that both of these ideas, the :where() guard and the opt-out class, are borrowed from @tailwindcss/typography, which has a .not-prose of its own. Which brings us to the comparison you actually clicked for.

shadcn/typeset vs Tailwind Typography

The Tailwind CSS Typography plugin, and the .prose class it hands you, has been the default answer for styling rendered markdown for years, and it's still excellent at what it was built for. Typeset isn't a drop-in replacement or a wrapper around it. It's a different set of trade-offs. The docs lay them out directly:

Tailwind Typography (.prose) Typeset
Sizing Fixed rem scale, .prose-sm to .prose-2xl Relative to the container, any size
Dark mode .prose-invert, a second palette Your tokens flip, nothing to add
Overrides prose-a:, prose-headings: modifier API Plain utilities and CSS win
Streaming No append-stability contract Designed for stable appends
Distribution npm plugin, generated CSS One CSS file you own

The way I'd frame it: .prose is a well-designed dependency you configure, and Typeset is a starting point you edit. If you like the modifier API and want a maintained plugin tracking Tailwind releases, .prose is still a great pick. If you'd rather own the file and delete the rules you don't want, Typeset fits that instinct better. It's the same philosophy as the rest of shadcn/ui: copy the code in, make it yours.

What makes it streaming-safe?

This is the part that made Typeset feel designed for 2026 rather than 2020. If you've built a chat UI that renders an LLM's markdown as it streams, you've watched the layout twitch as each token lands. Typeset treats that as a first-class constraint.

The rule is that there are no forward-looking selectors in the layout. :last-child, :has(), and :empty are deliberately kept out of spacing rules, because what they match changes as content appends, which restyles blocks that were already on screen. Spacing flows in one direction only, using margin-block-start and never margin-block-end:

& :where(p) {
  margin-block-start: var(--typeset-flow);
  margin-block-end: 0;
}

The same discipline shows up in tables: the row separators sit on the cells rather than on tr:last-child, so appending a row never re-runs a :last-child match and shifts the borders above it. When you add a new block to a Typeset container, nothing before it moves. That's a genuinely hard property to get right by hand, and it's baked into the stylesheet.

What it costs you

Two things are worth knowing before you commit.

First, the stylesheet leans on modern CSS, though most of it is safe by now: color-mix() and oklch() colors have been widely supported since 2023, and logical properties like margin-block-start work everywhere. The real outlier is margin-trim, which as of 2026 still ships only in Safari and other WebKit browsers (16.4 and up). There's no fallback for it, so in Chrome and Firefox that rule quietly does nothing. Worth knowing before you count on the margin trimming it's meant to handle.

Second, wide tables wrap to fit by default. Horizontal scroll is opt-in through a .typeset-scroll wrapper you have to add in your renderer's table component or a small rehype plugin. It won't happen automatically.

Should you use it?

If you're already in the shadcn/ui world and you're styling rendered markdown, especially anything that streams, Typeset is an easy yes: it's free, it's one file, and the streaming stability alone justifies it. If you're outside that ecosystem and happy with .prose, there's no migration to rush. It's an alternative with a clearer answer to a couple of specific problems, not an upgrade you're missing out on.

What I appreciate is the framing. shadcn keeps betting that the right unit of distribution for UI is source you own rather than a dependency you configure, and Typeset applies that bet to typography. I made a similar call rebuilding this site: the long-form styling here is hand-written Tailwind wired to theme variables rather than a plugin, for exactly the reason Typeset exists. Owning the CSS means when something looks off, you fix the rule instead of fighting the abstraction. Go build one in the builder and read the file it hands you. It's short, and reading it teaches you more about typographic CSS than the docs do.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Comparison: Universal Import vs. Plaid/Yodlee

Pocket Portfolio - Mar 12

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9
chevron_left
968 Points18 Badges
Milan, Italymorello.dev
8Posts
2Comments
6Connections
Passionate Frontend Engineer and Open Source contributor

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!