Before — every article route
<article class="content-grid max-w-noneprose prose-lg prose-gray dark:prose-invertprose-p:font-serif prose-p:leading-[1.85]prose-p:mt-0 prose-headings:font-blackprose-headings:scroll-mt-24prose-a:text-primaryhover:prose-a:text-primary/80prose-pre:bg-mutedprose-pre:text-foregroundprose-code:text-foregroundprose-blockquote:border-primary/40prose-blockquote:text-muted-foreground">
After — every article route
<article class="content-gridtypeset typeset-article">

Rendered rather than screenshotted, because the point of the piece is that these are two states of a real file — and a picture of code can’t be diffed, copied, or proven current.

Outcome

Four long-form routes (blog, showcases, case studies, feed) each carried the same twelve prose-*: modifiers — heading weight, link color, paragraph font, code-block background, blockquote treatment — repeated in JSX class lists. One rule change meant four edits, and the actual decisions were scattered across a dozen Tailwind arbitrary-value classes with no single home.

I swapped Tailwind Typography for shadcn/typeset, moved the project-specific choices into a single .typeset-article block in global.css, and dropped every prose-*: class from the four route templates. Same visual result. One source of truth.


Challenge

The route templates all looked like this:

<article class="content-grid max-w-none prose prose-lg prose-gray dark:prose-invert
  prose-p:font-serif prose-p:leading-[1.85] prose-p:mt-0
  prose-headings:font-black prose-headings:scroll-mt-24
  prose-a:text-primary hover:prose-a:text-primary/80
  prose-pre:bg-muted prose-pre:text-foreground
  prose-code:text-foreground
  prose-blockquote:border-primary/40 prose-blockquote:text-muted-foreground">
  <Content />
</article>

Four of those, near-identical. The modifiers weren’t wrong — they encoded real project choices (Erode on paragraphs, black headings, primary-green links). But every choice lived twelve levels deep in a Tailwind arbitrary-value class, on every route that rendered markdown. There was no .article-shaped surface to reason about.

Constraint that killed the easy answer: Astro’s per-page templates meant I couldn’t just wrap everything in a shared React component. The layout differences between blog (max-w-3xl centered) and showcases (content-grid max-w-none full-bleed) are load-bearing — the grid is what lets images break out to breakout and full-width tracks. Whatever replaced prose had to be composable with the surrounding layout chrome, not a container of its own.


Process

Why typeset

Two ideas in the docs matched my problem exactly. One CSS file you own — a stylesheet dropped next to global.css, not a plugin generating CSS at build time. Project-specific typography belongs in a file I edit directly, not behind a plugin version pin. And three controls: size, leading, flow — “rhythm.” Every downstream value (heading sizes, list indents, gap under a heading, spacing around a rule) derives from those three. That’s the same instinct I’d been trying to encode with per-modifier prose-*: classes, minus the surface area.

The install

  1. Downloaded typeset.css next to global.css.
  2. Imported it above the @tailwind directives (PostCSS requires all @import statements first).
  3. Wired the project’s variable fonts (Erode-Variable, Satoshi-Variable) into --erode and --satoshi on :root, then referenced them from .typeset-article.
  4. Ported the surviving prose-*: overrides into a small unlayered block on .typeset-article:
.typeset-article :where(h1, h2, h3, h4, h5, h6) {
  font-weight: 900;
  scroll-margin-top: 6rem;
}
.typeset-article :where(a) { color: hsl(var(--primary)); }
.typeset-article :where(a:hover) { color: hsl(var(--primary) / 0.8); }
.typeset-article :where(pre) {
  background: hsl(var(--muted));
  color: hsl(var(--foreground));
}
.typeset-article :where(code) { color: hsl(var(--foreground)); }
.typeset-article :where(blockquote) {
  border-color: hsl(var(--primary) / 0.4);
  color: hsl(var(--muted-foreground));
}

Unlayered on purpose. typeset’s own rules live inside @layer components, and unlayered rules trump any layered rule regardless of specificity — so :where() gives me zero-specificity selectors that still win. The next author who wants to override a heading weight can do it with a plain .typeset-article h2 { … } and won’t have to fight !important.

  1. Swapped prose prose-lg prose-gray dark:prose-invert prose-p:font-serif … for typeset typeset-article on all four route templates.
  2. Renamed the four not-prose islands (ReaderUpgradeLab, CodeBlock, Prototype, a snippet inside loading-carousel) to not-typeset.
  3. Removed @tailwindcss/typography from tailwind.config.cjs and npm uninstalled it.

Two things broke, both instructive

Vite build died with Cannot find module 'postcss-selector-parser'. @tailwindcss/typography had been the hoisted supplier of that transitive for Tailwind’s postcss-nesting chain. Uninstalling typography pruned the copy npm was resolving to. Fix: install postcss-selector-parser explicitly as a devDependency so the chain stops relying on hoisting luck.

PostCSS warning: @import must precede all other statements. I’d put @import "./typeset.css" after the @tailwind directives. PostCSS wants all @import at the top of the file. One reorder.

Both are the shape of design-engineering work: the visible change is a swap of one wrapper for another; the invisible change is packaging and CSS ordering. Neither cost much time, but both are the kind of second-order failure you only find by shipping and building.

Iteration — tuning the rhythm

The first pass shipped at --typeset-size: 16px because I’d matched the typeset docs’ default without checking what the old prose-lg was actually resolving to. On the real routes it read tight: the retired prose-p:leading-[1.85] had been propping up 16px body copy that needed the extra air.

The rhythm made it a one-line fix. Bumped --typeset-size to 18px and left --typeset-leading: 1.75 and --typeset-flow: 1.25em alone. Heading sizes, list indents, and block spacing all rescaled from that single value — the whole document breathed at once.

An article body at a 16px typeset base: two section headings and three paragraphs fit the frame, the measure running noticeably narrow
–typeset-size: 16px — the shipped first pass
The same article body at an 18px typeset base: the same passage now fills the frame, headings larger, lines further apart, the second heading pushed to the edge
–typeset-size: 18px — after the one-line change

Same route, same scroll position, same viewport — the only difference is the one custom property. Note what moved besides the body text: heading size, the gap between blocks, and the measure. Nothing in the pair was set by hand at 18px. That is what “one control” has to mean to be worth having, and it is the part a class list of twelve modifiers cannot do, because there the twelve values have no relationship to each other.

A rule I’m now enforcing

Project-specific typography lives in a .typeset-* block in global.css, never in per-route class lists. Route templates get exactly two typography classes: typeset and one preset. New surfaces that need different rhythm get a new preset, not a bag of Tailwind modifiers.


Solution

Every markdown-rendering surface on the site now looks like:

<article class="content-grid max-w-none typeset typeset-article">
  <Content />
</article>

.typeset-article in global.css owns the decisions:

.typeset-article {
  --typeset-font-body: var(--erode);
  --typeset-font-heading: var(--satoshi);
  --typeset-font-mono: var(--font-erode-mono);
  --typeset-size: 18px;
  --typeset-leading: 1.75;
  --typeset-flow: 1.25em;
}

Plus the ~10-line unlayered override block above. Everything else — heading rhythm, list indentation, table borders, inline code styling — is typeset’s defaults. not-typeset (and [data-not-typeset]) opts an island out with a one-word rename from the old not-prose.


Impact

Prose-related classes on the four route templates: ~48 (twelve per route × four) → 8 (typeset typeset-article per route × four). One place to change site-wide typography instead of four. One dependency removed (@tailwindcss/typography), one added (postcss-selector-parser as a direct devDep — needed to survive the uninstall).

I haven’t paid the maintenance cost yet. The swap is one day old, so the “one place to change it” claim is still a bet. The nearest real test comes the next time I want to tune something — a heading weight, a link color, the paragraph leading. When that happens I’ll come back and say whether the payoff landed.

What I’d do differently

  • Two files still reference .prose by name in narrative copy (subframe-design-system.mdx, DesignSystemGrid.tsx). I left that in this pass — it’s writing, not code — but it’s a follow-up.
  • I should have written a Playwright screenshot diff on the four routes before the swap. The visual difference between prose and typeset at matched settings is small but not zero, and I confirmed by eye. On a team codebase that diff would be pre-merge, not pre-deploy.

Reflection

prose was the right starting point. It gave me sensible long-form typography for free while the site’s own opinions were still forming. Once those opinions matured — Erode on paragraphs, Satoshi on headings weight 900, primary-green on links, muted background on code — repeating them as prose-*: modifiers on every route template stopped being expedient and started being noise. typeset’s framing gave me a place to put those matured opinions.

The door I left open on purpose: multiple presets. .typeset-article is what the site needs today. A chat surface, a compact settings panel, or a reader-mode toggle would each get its own .typeset-* — no per-route class list, no fighting a plugin.