Typography
Prose styles for HTML you don't control. One recipe, five sizes, colors from your tokens.
Markdown, a CMS, an AI reply: content you render but didn't write, arriving as plain headings, paragraphs, lists, and tables with no classes to hook onto. The typography preset styles all of it from one wrapper.
The article below is raw HTML. Nothing inside it carries a class. Switch the size to see the whole scale move together, and toggle the site's theme to see it follow your dark mode. Every post on the blog is rendered the same way.
Shipping a token change without a redesign
A design system earns its keep the first time a color changes and nobody has to open a component. Here is how that goes when the tokens are the contract.
Every component reads roles, not values: bg: 'surface', color: 'fg.muted'.
The palette behind those roles lives in one file. Change the file, and the change lands everywhere
the role is used, in light and dark, with no search and replace.
What a change looks like
Three steps, and the last one is the only one that touches code:
- Design picks the new value in the token sheet.
- The sheet exports to
tokens.json. - The config reads the JSON and the build regenerates the CSS variables.
The best token change is the one no component author finds out about.
Where roles come from
A role names a job, not a color. The usual starting set is small:
- canvas and surface for the page and the cards on it
- fg with muted and subtle steps for text
- accent for the one color that means "act here"
| Role | Light | Dark |
|---|---|---|
surface | white | gray.800 |
fg | gray.900 | gray.50 |
accent | blue.600 | blue.300 |
In the config
semanticTokens: {
colors: {
surface: { value: { base: '{colors.white}', _dark: '{colors.gray.800}' } },
accent: { value: { base: '{colors.blue.600}', _dark: '{colors.blue.300}' } }
}
}
Press ⌘ S and the dev server picks it up. Read more in the tokens guide.
That is the whole workflow. The rest of this page is about how this article is styled.
Install
pnpm add -D @pandacss/preset-typographypanda.config.ts
import { defineConfig } from '@pandacss/dev'
import typographyPreset from '@pandacss/preset-typography'
export default defineConfig({
presets: ['@pandacss/preset-base', '@pandacss/preset-panda', typographyPreset()]
})Run codegen and a prose recipe appears next to your other recipes.
Wrap the content
The wrapper
prose() returns a class name. Put it on the element that holds the content, and everything inside is styled:
import { prose } from '../styled-system/recipes'
export function Article({ html }: { html: string }) {
return <article className={prose()} dangerouslySetInnerHTML={{ __html: html }} />
}It works the same on rendered Markdown or any other markup you didn't write. The children don't need classes.
Sizes
The scale runs sm, md, lg, xl, and 2xl, with md as the default. A size is one font size on the root, and
every heading, gap, and code block is a ratio of it, so the article keeps its proportions at any step:
<article className={prose({ size: 'lg' })} />Follow the container
Because everything is a ratio, a prose can take its size from wherever it sits instead of from a variant. A
sidebar or a chat bubble that sets its own font size scales the whole article with it:
<aside className={css({ fontSize: 'sm' })}>
<article className={cx(prose(), css({ fontSize: 'inherit' }))} />
</aside>Lead paragraph
A paragraph with the lead class renders as an intro, larger and lighter than the body. It's the one class the
recipe looks for inside the content:
<p class="lead">A short opening that sets up the rest of the article.</p>
<p>Body text continues at the normal size.</p>Change the colors
The recipe reads roles like prose.link and prose.heading from semanticTokens. Every semantic token has a light and a dark value. Override the ones you care about in your config:
panda.config.ts
export default defineConfig({
presets: ['@pandacss/preset-base', '@pandacss/preset-panda', typographyPreset()],
theme: {
extend: {
semanticTokens: {
colors: {
prose: {
link: { value: { base: '{colors.blue.700}', _dark: '{colors.blue.300}' } },
heading: { value: '{colors.fg}' }
}
}
}
}
}
})The defaults come from one palette, neutral unless you say otherwise. To base them on another palette:
typographyPreset({ semanticTokens: { colorPalette: 'gray' } })The full set of roles: body, heading, lead, link, linkDecoration, bold, counter, bullet, hrBorder,
quote, quoteBorder, caption, kbd, code, codeBg, preCode, preBg, thBorder, and tdBorder.
Opt a component out
Content sometimes carries a component of your own, a callout or an embed with its own styles. Turn on notProse and
mark the component:
typographyPreset({ notProse: true })<article className={prose()}>
{content}
<div className="not-prose">
<Callout>Styled by the callout, not the prose.</Callout>
</div>
</article>Pass a string instead of true to use your own class name.
Tune the rhythm
Two custom properties on the root set the rhythm. --prose-leading is the line height, 1.625 by default, and
--prose-flow is the gap between blocks, 1.25em of the root font size. Headings and larger blocks are multiples of
the flow, so changing it moves the whole article together:
<article className={cx(prose(), css({ '--prose-leading': '1.5', '--prose-flow': '1em' }))} />The property names follow the recipe's name, so a preset created with name: 'article' uses --article-flow.
Override a style
Every rule the recipe emits is wrapped in :where(), so it has no specificity of its own. A plain style on the wrapper
or on an element inside wins without !important:
<article className={cx(prose(), css({ maxW: '4xl', '& h2': { fontFamily: 'heading' } }))} />The default width is the sizes.prose token, sixty characters. Override it the same way.
Stream content
Spacing is applied from the top of each block only, and no rule depends on :last-child, :has(), or :empty. When
content arrives a block at a time, as it does from an AI model, a new block never restyles the ones already on screen.
Options
typographyPreset({
// Recipe name, class name, and token prefix. Default 'prose'.
name: 'prose',
// Class name when it must differ from the name.
className: 'prose',
// Sizes to generate. Default all five.
sizes: ['sm', 'md', 'lg', 'xl', '2xl'],
// Size when the call site passes none. Default 'md'.
defaultSize: 'md',
// Class that opts a subtree out. true means 'not-prose'. Default off.
notProse: true,
semanticTokens: {
// Set false to define every role yourself.
enabled: true,
// Token prefix. Defaults to the name.
prefix: 'prose',
// Palette the defaults are built from.
colorPalette: 'neutral'
}
})