Toggle Sidebar B
AppearanceLight & dark mode D

Typeset

A styling system for HTML and rendered markdown, from blog posts to streaming chat. One CSS file you own.

You render markdown and get back plain unstyled HTML: headings, paragraphs, lists, and tables. So you style the elements one by one: font sizes, line heights, spacing.

You do it for your blog. Then you do it again for the docs. Then again for the chat app. Every time you're fighting the same thing: sizing and spacing.

To fix this, we created shadcn/typeset. It's 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.

A typeset is just a small preset class. You can have multiple typesets in your app, for different contexts.

css
.typeset-docs {
  --typeset-font-body: var(--font-sans);
  --typeset-font-heading: var(--font-sans);
  --typeset-font-mono: var(--font-mono);
  --typeset-size: 15px;
  --typeset-leading: 1.75;
  --typeset-flow: 1.25em;
}
Dark previewReplay preview

Reading rhythm

Typeset styles everything inside the container, so rendered markdown needs no per-element classes. Headings, lists, tables, and code all derive their spacing from three values.

  • Size sets the base text size.
  • Leading sets the space between lines.
  • Flow sets the space between blocks.

Everything else follows from those three.

Inline elements come along for free: bold, italic, links, and inline code.

Principles

We read a lot about type: scale ratios, tracking, kerning, optical sizing, measure, leading, the space above and below every element. We tried exposing all of it, and it was too much. Nobody wants to set a dozen variables to make markdown look right.

So we sat down and condensed everything into three controls: size, leading, and flow. Everything else, heading sizes, list indents, the gap under a heading, the space around a rule, derives from them. Three controls. We called it rhythm.

Features

  • It fits its container. Put it in a chat bubble and it follows the smaller type around it. Put it in an article and it scales up with the page. On smaller screens, it gets a small bump for readability.
  • It uses your theme. Colors, fonts, and radius come from your app. Dark mode follows the same tokens.
  • It's easy to tune. Three values control the base size, line height, and space between blocks. Change them in a preset and the whole document follows.
  • It works well with streaming. When a new block arrives, Typeset doesn't make earlier blocks switch margins, borders, or styles.
Dark previewReplay preview

Inside a bubble the text follows the smaller type around it.

No override needed.

Inside an article it scales up with the page instead.

Building Your Typeset

Create your typeset in the typeset builder. Pick your fonts and rhythm, then preview them on docs, chat, articles, and other real content.

The panel gives you the typeset.css file, the font setup for your framework, a preset class with your choices, and the wrapper to add around your content.

Copy typeset.css next to your main CSS file and import it after Tailwind:

css
@import "tailwindcss";
@import "./typeset.css";
vue
<div class="typeset typeset-docs">
  <ContentRenderer :value="page" />
</div>

Custom Typesets

The file includes defaults, so you can use typeset by itself. Most of the reading rhythm comes from three values:

css
.typeset {
  --typeset-font-body: inherit;
  --typeset-font-heading: inherit;
  --typeset-font-mono: var(--font-mono);

  --typeset-size: 1rem; /* body font-size */
  --typeset-leading: 1.75; /* line-height */
  --typeset-flow: 1.25em; /* space between blocks */
}
  • --typeset-size sets the base text size. 1rem on phones, stepping up at sm. Upstream ships the inverse ladder.
  • --typeset-leading sets the space between lines.
  • --typeset-flow sets the space between blocks. Headings and other elements derive their spacing from it.
Dark previewReplay preview

Roomy

Two ems between blocks, line height at 1.9.

Long-form reading benefits from the extra air.

Tight

Three quarters of an em between blocks, line height at 1.5.

Denser panels and chat surfaces read better this way.

The font variables tell Typeset which families to use. Leave them alone and it follows your app. Colors and radius come from your theme too.

Typeset doesn't set a maximum width. Your layout owns that. The Measure control in the builder adds a max-width to the wrapper instead of hiding it in the stylesheet.

You can keep more than one preset in the same app. Here is a tighter one for chat and a roomier one for docs:

css
.typeset-chat {
  --typeset-flow: 1em;
  --typeset-leading: 1.6;
}

.typeset-docs {
  --typeset-size: 15px;
  --typeset-flow: 1.5em;
}
vue
<div class="typeset typeset-chat">{{ message }}</div>
<article class="typeset typeset-docs"><ContentRenderer :value="page" /></article>

For a one-off change, skip the preset and set a value on the container:

vue
<article class="typeset [--typeset-flow:1.75em]">...</article>

Custom Themes

A preset can change the whole feel of the content, not just the spacing. You can give readers a serif reading mode, a compact UI mode, or any other style that fits your product.

css
/* Reading: serif, larger type, roomy rhythm. */
.typeset-reading {
  --typeset-font-body: var(--font-lora);
  --typeset-font-heading: var(--font-lora);
  --typeset-size: 18px;
  --typeset-leading: 1.9;
  --typeset-flow: 2em;
}

/* Compact: sans, smaller type, tighter rhythm. */
.typeset-compact {
  --typeset-font-body: var(--font-sans);
  --typeset-font-heading: var(--font-sans);
  --typeset-size: 14px;
  --typeset-leading: 1.6;
  --typeset-flow: 1em;
}
Dark previewReplay preview

typeset

Sixteen pixels on phones, eighteen from the sm breakpoint.

typeset-sm

Fourteen pixels with a tighter flow. Use it inside dialogs and cards.

typeset-fixed

Stays at the same size on every viewport. Use it when a pane must not grow.

Accessibility and Dark Mode

For readers who prefer larger type and more space, create a roomier typeset and expose it as a setting:

css
.typeset-large {
  --typeset-size: 16px;
  --typeset-leading: 2;
  --typeset-flow: 2em;
}

Dark mode already follows your theme colors. If the text feels a little tight on a dark surface, you can loosen the leading there:

css
.dark .typeset {
  --typeset-leading: 1.9;
}

Responsive Table

Every table is its own scroll container, so a wide one scrolls instead of pushing the page sideways. No wrapper needed. Upstream expects typeset-scroll here, and that class still works for other wide blocks.

Two exceptions. The live editor, because TipTap resizes columns through a real table box. And a typeset container that is itself a flex item, which needs min-w-0 before max-width can bind.

Dark previewReplay preview

Wide tables

Every table inside typeset is its own scroll container, so a table wider than the measure scrolls instead of pushing the page sideways. Narrow the window to see it kick in.

RegionSessionsSignupsConversion
North12,4803182.5%
South9,2404024.3%

typeset-cms

Content out of a database arrives with images at arbitrary widths and cells that wrap into a column of stacked words. typeset-cms fills the measure with images and keeps cell text on one line. Hand-written pages leave it off.

Dark previewReplay preview

Rendered from the database

A CMS surface never knows what the editor pasted. Images arrive at whatever width the uploader gave them, and the editor wraps every cell in its own paragraph.

A black and white photo of a circular object

The image above fills the measure instead of sitting at its natural 600px, and the cells below stay on one line rather than collapsing into a column of stacked words.

RegionSessionsSignupsConversionTrend
North12,4803182.5%Up from last month
South9,2404024.3%Steady

Overrides

Typeset lives in the components layer and uses :where() for its element selectors. Tailwind utilities on an element win without !important:

vue
<div class="typeset typeset-docs">
  <p class="text-lg">...</p>
</div>

Opting Out

To keep a component out of Typeset, add not-typeset or data-not-typeset:

vue
<div class="typeset">
  <p>Styled prose.</p>
  <Card class="not-typeset">Untouched component.</Card>
</div>

Both options cover the component and everything inside it. Another typeset container inside that subtree stays opted out too.

Dark previewReplay preview

Opting out

Anything inside the container inherits the document styles.

Card one
Untouched by Typeset.
Card two
Also untouched.

The paragraph after the opt-out picks the rhythm back up.

Code

Block code takes the muted surface, the mono family, and the radius from your theme. Inline code gets a smaller pill on the same surface, scoped so nested code inside a block is left alone.

Dark previewReplay preview

Code

Block code takes the muted surface and the mono family from your theme. Inline code gets a smaller pill on the same surface.

export default defineNuxtConfig({
  css: ["~/assets/css/main.css"]
})

Highlighted output keeps its own token colours in both themes.

Streaming

Typeset is written so that adding a new block does not change the styles of the blocks already on screen.

  • No forward-looking selectors. :last-child, :has(), and :empty are left out of layout rules because their matches can change as content is added.
  • Spacing flows in one direction, using margin-block-start only. A new block adds its own space.
  • Table separators live on the cells being added, so a new row does not restyle the row above it.

Text that is still streaming can grow and wrap normally. Typeset just avoids restyling the blocks that came before it.

Prior Art

The prose class from @tailwindcss/typography is excellent at what it was built for: adding beautiful typographic defaults to plain HTML, including content rendered from Markdown or a CMS.

Typeset takes a different approach with container-aware sizing, app theme tokens, presets for different contexts, and streaming stability. Here's where they differ:

text
              @tailwindcss/typography                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
Theming       Prose color variables; scale baked in   Your theme tokens, plus font and rhythm
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

Typeset borrows the two best ideas from the plugin: the zero-specificity :where() guard pattern, and the escape-hatch class (not-typeset, in the spirit of not-prose).

API Reference

Container classes

PropTypeDefaultDescription
typesetclassTurns the styles on. Everything inside the container is styled.
typeset-smclassCompact preset: 14px with a 1em flow.
typeset-fixedclassKeeps one size at every viewport.
typeset-cmsclassFull-bleed images and single-line table cells, for HTML out of a database.
not-typesetclassOpts a subtree out. data-not-typeset works the same way.
typeset-scrollclassMakes a wide child scroll. Tables already scroll on their own.