# One Wrapper Class Instead of Twelve Prose Modifiers

> Swapped Tailwind Typography for shadcn/typeset across every long-form surface — twelve prose modifiers on four routes became one wrapper class.

- Author: Alejandro Haydar
- Published: 2026-08-12
- Taxonomy: Design Engineering, Design Systems, Workflows, Showcase
- Canonical: https://alejandroastroport.netlify.app/showcases/from-prose-to-typeset/

---
import { Image } from "astro:assets";
import { CodeBlock } from "../../components/mdx/CodeBlock";
import size16 from "../../assets/images/typeset-swap/size-16.png";
import size18 from "../../assets/images/typeset-swap/size-18.png";

<div class="breakout grid grid-cols-1 md:grid-cols-2 gap-4 my-8">
  <CodeBlock
    client:visible
    filename="Before — every article route"
    language="html"
    hideLineNumbers
    code={`<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">`}
  />
  <CodeBlock
    client:visible
    filename="After — every article route"
    language="html"
    hideLineNumbers
    code={`<article class="content-grid
  typeset typeset-article">`}
  />
</div>

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](https://ui.shadcn.com/docs/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:

```html
<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`:

```css
.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`.

5. Swapped `prose prose-lg prose-gray dark:prose-invert prose-p:font-serif …` for `typeset typeset-article` on all four route templates.
6. Renamed the four `not-prose` islands (`ReaderUpgradeLab`, `CodeBlock`, `Prototype`, a snippet inside `loading-carousel`) to `not-typeset`.
7. Removed `@tailwindcss/typography` from `tailwind.config.cjs` and `npm uninstall`ed 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.

<div class="breakout grid grid-cols-1 md:grid-cols-2 gap-4 my-8">
  <figure>
    <Image src={size16} alt="An article body at a 16px typeset base: two section headings and three paragraphs fit the frame, the measure running noticeably narrow" />
    <figcaption class="text-sm text-neutral-500 mt-2"><code>--typeset-size: 16px</code> — the shipped first pass</figcaption>
  </figure>
  <figure>
    <Image src={size18} alt="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" />
    <figcaption class="text-sm text-neutral-500 mt-2"><code>--typeset-size: 18px</code> — after the one-line change</figcaption>
  </figure>
</div>

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:

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

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

```css
.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.