ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Typeset

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, Zaidan ports 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.

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

Principles

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

So everything condenses 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. Call 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.

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.

Every pick lands in the URL as a typeset code, so the design you are looking at is the design you can share. That code is also an installable registry item — it pulls in the stylesheet, the fonts you picked, and your preset class in one go:

pnpm dlx shadcn@latest add @zaidan/typeset-a1JbnM
npx shadcn@latest add @zaidan/typeset-a1JbnM
yarn dlx shadcn@latest add @zaidan/typeset-a1JbnM
bunx --bun shadcn@latest add @zaidan/typeset-a1JbnM

The panel gives you that command, the preset class with your choices, and the wrapper to add around your content — plus a single prompt you can hand to a coding agent.

To wire it up by hand instead, add the stylesheet to your project:

pnpm dlx shadcn@latest add @zaidan/typeset
npx shadcn@latest add @zaidan/typeset
yarn dlx shadcn@latest add @zaidan/typeset
bunx --bun shadcn@latest add @zaidan/typeset

This copies typeset.css next to your main CSS file and adds the import for you. If you'd rather wire it by hand, import it after Tailwind:

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

Then define a preset with your fonts and rhythm, and wrap your rendered markdown with typeset plus that preset class:

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

typeset turns the styles on. typeset-docs is your preset.


Custom Typesets

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

.typeset {
--typeset-font-body: inherit;
--typeset-font-heading: var(--font-heading);
--typeset-font-mono: var(--font-mono);
--typeset-size: 1em; /* body font-size */
--typeset-leading: 1.75; /* line-height */
--typeset-flow: 1.25em; /* space between blocks */
}
  • --typeset-size sets the base text size. 1em follows the surrounding layout. On smaller screens, Typeset bumps it up a little.
  • --typeset-leading sets the space between lines.
  • --typeset-flow sets the space between blocks. Headings and other elements derive their spacing from it.

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.

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

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

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

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

Pair these with the matching font from the registry — @zaidan/font-lora and @zaidan/font-geist for the examples below.

/* Reading: serif, larger type, roomy rhythm. */
.typeset-reading {
--typeset-font-body: "Lora Variable", serif;
--typeset-font-heading: "Lora Variable", serif;
--typeset-size: 18px;
--typeset-leading: 1.9;
--typeset-flow: 2em;
}
/* Compact: sans, smaller type, tighter rhythm. */
.typeset-compact {
--typeset-font-body: "Geist Variable", sans-serif;
--typeset-font-heading: "Geist Variable", sans-serif;
--typeset-size: 14px;
--typeset-leading: 1.6;
--typeset-flow: 1em;
}

Accessibility and Dark Mode

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

.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:

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

Responsive Table

Tables stay real tables and wrap to fit. To scroll a wide one horizontally instead, wrap it in typeset-scroll:

<div class="typeset-scroll">
<table>...</table>
</div>

Do this in your renderer's table component or a small rehype plugin. It works for any wide block, not just tables.


Overrides

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

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

Plain CSS can override Typeset with a normal selector too.


Opting Out

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

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


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.

This is a contract, not a convention: it's enforced by a test that fails the build if a forward-looking selector or a backward margin ever lands in the stylesheet.


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:

@tailwindcss/typographyTypeset
SizingFixed rem scale, prose-sm to prose-2xlRelative to the container, any size
Dark modeprose-invert, a second paletteYour tokens flip, nothing to add
ThemingProse color variables; scale baked inYour theme tokens, plus font and rhythm controls
Overridesprose-a:, prose-headings: modifier APIPlain utilities and CSS win
StreamingNo append-stability contractDesigned for stable appends
Distributionnpm plugin, generated CSSOne 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).

On This Page

  • Principles
  • Features
  • Building Your Typeset
  • Custom Typesets
  • Custom Themes
  • Accessibility and Dark Mode
  • Responsive Table
  • Overrides
  • Opting Out
  • Streaming
  • Prior Art
Built by Kevin Abatan. The source code is available on GitHub.