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 did not 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.
Add the preset
It ships with Bamboo and is opt-in. List it after the default presets, which provide the tokens it is built on — the
prose width, the neutral palette and the font sizes:
bamboo.config.ts
import { defineConfig } from '@bamboocss/dev'
import { defaultPresets, typographyPreset } from '@bamboocss/dev/presets'
export default defineConfig({
presets: [...defaultPresets, typographyPreset()],
})A prose recipe appears next to your other recipes. Listed and never called, it adds nothing to the stylesheet.
Wrap the content
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 did not write. The children need no 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' })} />
A size known only at run time compiles too, to a lookup among the sizes:
<article className={prose({ size: compact ? 'sm' : '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 is 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, each with a light and a dark value.
Override the ones you care about:
bamboo.config.ts
export default defineConfig({
presets: [...defaultPresets, typographyPreset()],
theme: {
extend: {
semanticTokens: {
colors: {
prose: {
link: { value: { base: 'token(colors.blue.700)', _dark: 'token(colors.blue.300)' } },
},
},
},
},
},
})The defaults come from one palette, neutral unless you say otherwise:
typographyPreset({ semanticTokens: { colorPalette: 'gray' } })
The roles are 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
Override through the wrapper, with the key the recipe writes the element under — & h2, & a, & code — and cx
merges your style over the recipe's for that key:
<article className={cx(prose(), css({ maxW: '4xl', '& h2': { color: 'blue.700' } }))} />
Each element has a key of its own, so an override of one heading leaves the others as the recipe styles them. A class on an element inside the article does not override it: the article's rule names the element and the wrapper both, and is the more specific.
The default width is the sizes.prose token, 65 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.
Class names
The recipe compiles to one class per element and property, about 150 on the wrapper. Readable names spell the selector
out, which notProse makes long. With hash: 'auto', the build hashes them and the dev
server keeps them readable.
Every value outside the theme is written as [value], so the preset reads the same under
strictValues as your own source does.
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',
},
})