Honest Prose
Style rendered Markdown and plain HTML with one class, in any project, including text that is still streaming.
Use the panel below the preview to adjust typography or start from the Chat, Document, and Compact presets. Advanced options override theme colors and code styling. Reset restores the Chat preset and theme defaults. Choose CSS or Tailwind under Export, then copy the current settings. Text width is measured in ch, approximately the width of the font's zero character.
Overview
Prose styles rendered Markdown and plain HTML. Use the honest-prose class with its stylesheet, or install the Tailwind utilities to get the styles without another CSS file.
It is plain CSS, so it works with any Markdown renderer and in a project without React or Tailwind. In an Honest UI project it reads the theme's tokens. Anywhere else it falls back to values based on the text color around it, so it follows your light and dark themes.
Markdown and Streaming Text use it for their text.
Installation
Tailwind v4, without a separate stylesheet
This copies lib/prose-tailwind.ts, with no package or stylesheet dependencies. Your project needs Tailwind v4 already configured.
import { proseTailwind } from "@/lib/prose-tailwind"
<article className={proseTailwind}>
<h2>Release checklist</h2>
<p>The export fix is ready.</p>
</article>The utilities use the same --prose-* variables and not-prose opt-out as the stylesheet. Set customized variables with an inline style, or choose Tailwind in the playground and paste the copied classes directly into your template.
Keep the installed file and any pasted classes inside Tailwind's source scan. If your lib folder is excluded, add @source "../lib/prose-tailwind.ts"; to your existing Tailwind stylesheet, adjusting the path. Tailwind cannot discover class names assembled from values at runtime; copy the generated classes into source or use proseTailwind with inline variables.
The generated utilities use Tailwind's important modifier so theme element rules cannot override them. Customize with the variables. Tailwind v3 is not supported by this install option.
// Generated from registry/styles/prose.css by npm run prose:generate.
// Tailwind v4: keep this file inside your source scan. No extra stylesheet is needed.
/** Complete prose styles. Customize with the --prose-* variables. */
export const proseTailwind = [
"[--prose-internal-size:var(--prose-size,var(--hui-font-size-regular,1em))]!",
"[--prose-internal-leading:var(--prose-leading,1.6)]!",
"[--prose-internal-flow:var(--prose-flow,var(--hui-space-4,0.85em))]!",
"[--prose-internal-heading:var(--prose-heading,1.143em)]!",
"[--prose-internal-color:var(--prose-color,var(--hui-color-foreground-base-primary,currentColor))]!",
"[--prose-internal-muted:var(--prose-muted,var(--hui-color-foreground-base-secondary,color-mix(in_oklab,currentColor_72%,transparent)))]!",
"[--prose-internal-surface:var(--prose-surface,var(--hui-color-background-base-secondary,color-mix(in_oklab,currentColor_7%,transparent)))]!",
"[--prose-internal-rule:var(--prose-rule,var(--hui-color-border-base-primary,color-mix(in_oklab,currentColor_18%,transparent)))]!",
"[--prose-internal-rule-strong:var(--prose-rule-strong,var(--hui-color-border-base-secondary,color-mix(in_oklab,currentColor_34%,transparent)))]!",
"[--prose-internal-font-mono:var(--prose-font-mono,var(--hui-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace))]!",
"[--prose-internal-radius:var(--prose-radius,var(--hui-radius-1,0.25em))]!",
"[min-width:0]!",
"[max-width:var(--prose-width,none)]!",
"[overflow-wrap:break-word]!",
"[color:var(--prose-internal-color)]!",
"[font-size:var(--prose-internal-size)]!",
"[line-height:var(--prose-internal-leading)]!",
"[letter-spacing:var(--prose-tracking,var(--hui-letter-spacing-regular,normal))]!",
"[&_:where(p,ul,ol,dl,blockquote,pre,table,figure,details,hr,.prose-scroll,.not-prose):not(:where(.not-prose_*))]:[margin-block:var(--prose-internal-flow)0]!",
"[&_:where(p,ul,ol,dl,blockquote,pre,figure):not(:where(.not-prose,.not-prose_*))]:[margin-inline:0]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[margin-block:calc(var(--prose-internal-flow)*_1.667)0]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[font-family:inherit]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[font-size:1em]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[font-weight:var(--hui-font-weight-semibold,600)]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[line-height:1.43]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[letter-spacing:inherit]!",
"[&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))]:[text-wrap:balance]!",
"[&&_:where(h1,h2):not(:where(.not-prose,.not-prose_*))]:[font-size:var(--prose-internal-heading)]!",
"[&&_:where(h1,h2):not(:where(.not-prose,.not-prose_*))]:[line-height:1.5]!",
"[&&_:where(h1,h2,h3,h4,h5,h6):not(:where(.not-prose,.not-prose_*))+_*]:[margin-block-start:calc(var(--prose-internal-flow)*_0.667)]!",
"[&_:where(a):not(:where(.not-prose,.not-prose_*))]:[color:inherit]!",
"[&_:where(a):not(:where(.not-prose,.not-prose_*))]:[text-decoration:underline]!",
"[&_:where(a):not(:where(.not-prose,.not-prose_*))]:[text-underline-offset:2px]!",
"[&_:where(strong,b):not(:where(.not-prose,.not-prose_*))]:[font-weight:var(--hui-font-weight-semibold,600)]!",
"[&_:where(code,kbd,samp):not(:where(.not-prose,.not-prose_*))]:[font-family:var(--prose-internal-font-mono)]!",
"[&_:where(code,kbd,samp):not(:where(.not-prose,.not-prose_*))]:[font-size:0.9em]!",
"[&_:where(:not(pre)>_code,kbd):not(:where(.not-prose,.not-prose_*))]:[border-radius:var(--prose-internal-radius)]!",
"[&_:where(:not(pre)>_code,kbd):not(:where(.not-prose,.not-prose_*))]:[background:var(--prose-internal-surface)]!",
"[&_:where(:not(pre)>_code,kbd):not(:where(.not-prose,.not-prose_*))]:[padding-inline:0.3em]!",
"[&_:where(kbd):not(:where(.not-prose,.not-prose_*))]:[border:0.5px_solid_var(--prose-internal-rule-strong)]!",
"[&_:where(ul,ol):not(:where(.not-prose,.not-prose_*))]:[padding-inline-start:1.45em]!",
"[&_:where(ul):not(:where(.not-prose,.not-prose_*))]:[list-style:disc]!",
"[&_:where(ol):not(:where(.not-prose,.not-prose_*))]:[list-style:decimal]!",
"[&&_:where(ul_ul):not(:where(.not-prose,.not-prose_*))]:[list-style:circle]!",
"[&_:where(li):not(:where(.not-prose,.not-prose_*))]:[margin-block:calc(var(--prose-internal-flow)/_6)0]!",
"[&&_:where(li)>_:where(ul,ol):not(:where(.not-prose,.not-prose_*))]:[margin-block-start:calc(var(--prose-internal-flow)/_6)]!",
"[&&_:where(ul.contains-task-list):not(:where(.not-prose,.not-prose_*))]:[list-style:none]!",
"[&&_:where(ul.contains-task-list):not(:where(.not-prose,.not-prose_*))]:[padding-inline-start:0]!",
"[&_:where(li.task-list-item_input):not(:where(.not-prose,.not-prose_*))]:[margin:0]!",
"[&_:where(li.task-list-item_input):not(:where(.not-prose,.not-prose_*))]:[margin-inline-end:0.4em]!",
"[&_:where(dt):not(:where(.not-prose,.not-prose_*))]:[font-weight:var(--hui-font-weight-medium,500)]!",
"[&_:where(dd):not(:where(.not-prose,.not-prose_*))]:[margin:0]!",
"[&_:where(dd):not(:where(.not-prose,.not-prose_*))]:[color:var(--prose-internal-muted)]!",
"[&_:where(blockquote):not(:where(.not-prose,.not-prose_*))]:[border-inline-start:2px_solid_var(--prose-internal-rule-strong)]!",
"[&_:where(blockquote):not(:where(.not-prose,.not-prose_*))]:[padding-inline-start:var(--prose-internal-flow)]!",
"[&_:where(blockquote):not(:where(.not-prose,.not-prose_*))]:[color:var(--prose-internal-muted)]!",
"[&&_:where(hr):not(:where(.not-prose,.not-prose_*))]:[margin-block-start:calc(var(--prose-internal-flow)*_1.333)]!",
"[&&_:where(hr):not(:where(.not-prose,.not-prose_*))]:[border:0]!",
"[&&&_:where(hr):not(:where(.not-prose,.not-prose_*))]:[border-block-start:0.5px_solid_var(--prose-internal-rule)]!",
"[&&_:where(hr):not(:where(.not-prose,.not-prose_*))]:[height:0]!",
"[&_:where(pre):not(:where(.not-prose,.not-prose_*))]:[overflow-x:auto]!",
"[&_:where(pre):not(:where(.not-prose,.not-prose_*))]:[border-radius:calc(var(--prose-internal-radius)*_3)]!",
"[&_:where(pre):not(:where(.not-prose,.not-prose_*))]:[background:var(--prose-internal-surface)]!",
"[&_:where(pre):not(:where(.not-prose,.not-prose_*))]:[padding:var(--prose-internal-flow)]!",
"[&_:where(pre):not(:where(.not-prose,.not-prose_*))]:[line-height:1.65]!",
"[&_:where(.prose-scroll):not(:where(.not-prose,.not-prose_*))]:[overflow-x:auto]!",
"[&_:where(table):not(:where(.not-prose,.not-prose_*))]:[width:100%]!",
"[&_:where(table):not(:where(.not-prose,.not-prose_*))]:[border-collapse:collapse]!",
"[&_:where(table):not(:where(.not-prose,.not-prose_*))]:[font-size:0.857em]!",
"[&_:where(table):not(:where(.not-prose,.not-prose_*))]:[line-height:1.34]!",
"[&&_:where(.prose-scroll)>_:where(table)]:[margin-block-start:0]!",
"[&_:where(th,td):not(:where(.not-prose,.not-prose_*))]:[padding:calc(var(--prose-internal-flow)/_3)calc(var(--prose-internal-flow)*_0.667)]!",
"[&_:where(th,td):not(:where(.not-prose,.not-prose_*))]:[vertical-align:top]!",
"[&_:where(th,td):not(:where(.not-prose,.not-prose_*))]:[text-align:start]!",
"[&_:where(th):not(:where(.not-prose,.not-prose_*))]:[font-weight:var(--hui-font-weight-medium,500)]!",
"[&&_:where(td):not(:where(.not-prose,.not-prose_*))]:[border-block-start:0.5px_solid_var(--prose-internal-rule)]!",
"[&_:where(img,video):not(:where(.not-prose,.not-prose_*))]:[max-width:100%]!",
"[&_:where(img,video):not(:where(.not-prose,.not-prose_*))]:[height:auto]!",
"[&_:where(figcaption):not(:where(.not-prose,.not-prose_*))]:[margin-block-start:calc(var(--prose-internal-flow)/_2)]!",
"[&_:where(figcaption):not(:where(.not-prose,.not-prose_*))]:[color:var(--prose-internal-muted)]!",
"[&_:where(figcaption):not(:where(.not-prose,.not-prose_*))]:[font-size:0.857em]!",
"[&&&_>_:where(:first-child)]:[margin-block-start:0]!",
"[&&&_:where(li,blockquote,td,th,dd,figure)>_:where(:first-child):not(:where(.not-prose_*))]:[margin-block-start:0]!",
].join(" ")
/** Static classes for copying into a template; paste the result into scanned source. */
export function proseTailwindClasses(settings: Record<string, string> = {}) {
const variables = Object.entries(settings).map(([name, value]) => {
if (!/^--prose-[a-z-]+$/.test(name) || /[;{}\[\]\\]/.test(value)) {
throw new Error("Invalid prose setting.")
}
return `[${name}:${value.replaceAll("_", "\\_").replace(/\s+/g, "_")}]`
})
return [proseTailwind, ...variables].join(" ")
}
Standalone CSS
Or copy it into styles/prose.css yourself:
/*
* Prose styles rendered Markdown and plain HTML: put `class="honest-prose"` on
* the element that wraps it. It is plain CSS and needs no build step.
*
* Set these on `.honest-prose`, or on any ancestor, to change it:
*
* --prose-size Body text size.
* --prose-width Maximum text width.
* --prose-leading Body line height, as a number.
* --prose-flow Space between blocks.
* --prose-heading Size of first- and second-level headings.
* --prose-tracking Body letter spacing.
* --prose-color, --prose-muted, --prose-surface,
* --prose-rule, --prose-rule-strong
* --prose-font-mono, --prose-radius
*
* Each one falls back to an Honest UI token, then to a value that works with
* no tokens at all. Prose never sets them itself, so a value from a utility
* class or an inline style always applies.
*
* Add `class="not-prose"` to anything inside that brings its own styles,
* such as a component. It keeps its place in the flow and nothing else.
*
* Blocks are spaced with a top margin only, and no rule depends on what comes
* after an element, so text that is still arriving does not move what is
* already on screen.
*/
.honest-prose {
--_size: var(--prose-size, var(--hui-font-size-regular, 1em));
--_leading: var(--prose-leading, 1.6);
--_flow: var(--prose-flow, var(--hui-space-4, 0.85em));
--_heading: var(--prose-heading, 1.143em);
--_color: var(--prose-color, var(--hui-color-foreground-base-primary, currentColor));
--_muted: var(
--prose-muted,
var(--hui-color-foreground-base-secondary, color-mix(in oklab, currentColor 72%, transparent))
);
--_surface: var(
--prose-surface,
var(--hui-color-background-base-secondary, color-mix(in oklab, currentColor 7%, transparent))
);
--_rule: var(
--prose-rule,
var(--hui-color-border-base-primary, color-mix(in oklab, currentColor 18%, transparent))
);
--_rule-strong: var(
--prose-rule-strong,
var(--hui-color-border-base-secondary, color-mix(in oklab, currentColor 34%, transparent))
);
--_font-mono: var(
--prose-font-mono,
var(--hui-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace)
);
--_radius: var(--prose-radius, var(--hui-radius-1, 0.25em));
min-width: 0;
max-width: var(--prose-width, none);
overflow-wrap: break-word;
color: var(--_color);
font-size: var(--_size);
line-height: var(--_leading);
letter-spacing: var(--prose-tracking, var(--hui-letter-spacing-regular, normal));
}
/* Flow */
.honest-prose
:where(p, ul, ol, dl, blockquote, pre, table, figure, details, hr, .prose-scroll, .not-prose):not(
:where(.not-prose *)
) {
margin-block: var(--_flow) 0;
}
.honest-prose :where(p, ul, ol, dl, blockquote, pre, figure):not(:where(.not-prose, .not-prose *)) {
margin-inline: 0;
}
/* Headings */
.honest-prose :where(h1, h2, h3, h4, h5, h6):not(:where(.not-prose, .not-prose *)) {
margin-block: calc(var(--_flow) * 1.667) 0;
font-family: inherit;
font-size: 1em;
font-weight: var(--hui-font-weight-semibold, 600);
line-height: 1.43;
letter-spacing: inherit;
text-wrap: balance;
}
.honest-prose :where(h1, h2):not(:where(.not-prose, .not-prose *)) {
font-size: var(--_heading);
line-height: 1.5;
}
/* A heading and the block under it read as one unit. */
.honest-prose :where(h1, h2, h3, h4, h5, h6):not(:where(.not-prose, .not-prose *)) + * {
margin-block-start: calc(var(--_flow) * 0.667);
}
/* Inline text */
.honest-prose :where(a):not(:where(.not-prose, .not-prose *)) {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.honest-prose :where(strong, b):not(:where(.not-prose, .not-prose *)) {
font-weight: var(--hui-font-weight-semibold, 600);
}
.honest-prose :where(code, kbd, samp):not(:where(.not-prose, .not-prose *)) {
font-family: var(--_font-mono);
font-size: 0.9em;
}
.honest-prose :where(:not(pre) > code, kbd):not(:where(.not-prose, .not-prose *)) {
border-radius: var(--_radius);
background: var(--_surface);
padding-inline: 0.3em;
}
.honest-prose :where(kbd):not(:where(.not-prose, .not-prose *)) {
border: 0.5px solid var(--_rule-strong);
}
/* Lists */
.honest-prose :where(ul, ol):not(:where(.not-prose, .not-prose *)) {
padding-inline-start: 1.45em;
}
.honest-prose :where(ul):not(:where(.not-prose, .not-prose *)) {
list-style: disc;
}
.honest-prose :where(ol):not(:where(.not-prose, .not-prose *)) {
list-style: decimal;
}
.honest-prose :where(ul ul):not(:where(.not-prose, .not-prose *)) {
list-style: circle;
}
.honest-prose :where(li):not(:where(.not-prose, .not-prose *)) {
margin-block: calc(var(--_flow) / 6) 0;
}
.honest-prose :where(li) > :where(ul, ol):not(:where(.not-prose, .not-prose *)) {
margin-block-start: calc(var(--_flow) / 6);
}
/* A task list's checkboxes stand in for the bullets. */
.honest-prose :where(ul.contains-task-list):not(:where(.not-prose, .not-prose *)) {
list-style: none;
padding-inline-start: 0;
}
.honest-prose :where(li.task-list-item input):not(:where(.not-prose, .not-prose *)) {
margin: 0;
margin-inline-end: 0.4em;
}
.honest-prose :where(dt):not(:where(.not-prose, .not-prose *)) {
font-weight: var(--hui-font-weight-medium, 500);
}
.honest-prose :where(dd):not(:where(.not-prose, .not-prose *)) {
margin: 0;
color: var(--_muted);
}
/* Quotes and rules */
.honest-prose :where(blockquote):not(:where(.not-prose, .not-prose *)) {
border-inline-start: 2px solid var(--_rule-strong);
padding-inline-start: var(--_flow);
color: var(--_muted);
}
.honest-prose :where(hr):not(:where(.not-prose, .not-prose *)) {
margin-block-start: calc(var(--_flow) * 1.333);
border: 0;
border-block-start: 0.5px solid var(--_rule);
height: 0;
}
/* Code blocks, for a renderer that does not bring a component for them */
.honest-prose :where(pre):not(:where(.not-prose, .not-prose *)) {
overflow-x: auto;
border-radius: calc(var(--_radius) * 3);
background: var(--_surface);
padding: var(--_flow);
line-height: 1.65;
}
/* Tables */
/* Wrap a table in `.prose-scroll` so a wide one scrolls instead of the page. */
.honest-prose :where(.prose-scroll):not(:where(.not-prose, .not-prose *)) {
overflow-x: auto;
}
.honest-prose :where(table):not(:where(.not-prose, .not-prose *)) {
width: 100%;
border-collapse: collapse;
font-size: 0.857em;
line-height: 1.34;
}
.honest-prose :where(.prose-scroll) > :where(table) {
margin-block-start: 0;
}
.honest-prose :where(th, td):not(:where(.not-prose, .not-prose *)) {
padding: calc(var(--_flow) / 3) calc(var(--_flow) * 0.667);
vertical-align: top;
text-align: start;
}
.honest-prose :where(th):not(:where(.not-prose, .not-prose *)) {
font-weight: var(--hui-font-weight-medium, 500);
}
/* The line is drawn above each body cell, so a row that arrives later needs no restyle. */
.honest-prose :where(td):not(:where(.not-prose, .not-prose *)) {
border-block-start: 0.5px solid var(--_rule);
}
/* Media */
.honest-prose :where(img, video):not(:where(.not-prose, .not-prose *)) {
max-width: 100%;
height: auto;
}
.honest-prose :where(figcaption):not(:where(.not-prose, .not-prose *)) {
margin-block-start: calc(var(--_flow) / 2);
color: var(--_muted);
font-size: 0.857em;
}
/* Last, so it wins over the flow rules above: nothing is pushed off the top of its container. */
.honest-prose > :where(:first-child),
.honest-prose :where(li, blockquote, td, th, dd, figure) > :where(:first-child):not(:where(.not-prose *)) {
margin-block-start: 0;
}
@import "tailwindcss";
@import "../styles/prose.css";Usage
<article class="honest-prose">
<h2>Release checklist</h2>
<p>The export fix is ready.</p>
</article>With a Markdown renderer, wrap its output:
<div className="honest-prose">
<ReactMarkdown>{text}</ReactMarkdown>
</div>Variables
Three variables do most of the work. Set them on the .honest-prose element or on anything around it.
The rest are for matching a theme:
Prose reads these variables and never sets them. A value from a class, an inline style, or an ancestor always applies:
<div className="honest-prose [--prose-size:16px] [--prose-leading:1.7] [--prose-flow:18px]">…</div>You can have several prose styles in one app: a tight one for chat replies and a roomier one for documents, each a different set of values on the same class.
The fallback colors use color-mix(). If you set your own contrast-sensitive colors, check them: the muted color carries text.
Behavior
Sizes
Headings are small on purpose. A reply sits inside a page that already has a title, so first- and second-level headings are a little larger than body text, and the rest match it in a heavier weight. For a document with a real title, raise --prose-heading or style h1 yourself.
Streaming
Blocks are spaced with a margin above them, never below. No rule depends on what comes after an element. A paragraph, list item, or table row that arrives later does not restyle what is already on screen.
Opting out
Add class="not-prose" to an element that brings its own styles, such as a component placed between paragraphs. It keeps its space in the flow, and no other rule reaches it or anything inside it. Code View carries this class itself.
Wide tables
Wrap a table in an element with class="prose-scroll", so a wide table scrolls sideways instead of widening the page. Make that wrapper focusable and give it a name, so a keyboard user can scroll it. Markdown does both.
Utility classes
Prose's rules sit outside Tailwind's layers, so a utility for a property Prose sets has no effect on the same element. text-sm on the .honest-prose element does not change its size. Use the variables, or your own CSS written after the import. Utilities for anything Prose does not set, such as width or padding on the wrapper, work as usual.
It is written this way because a theme's own heading rules also sit outside the layers, and would otherwise win.
Accessibility
Prose changes how elements look and leaves what they are alone. Headings, lists, tables, and links keep their roles.
Links are underlined, so they do not depend on color. Line height is a number without a unit, and spacing uses margins, so text a reader has enlarged or respaced is not clipped.
The preview has been checked with axe and keyboard controls in Chromium. Standalone HTML has also been checked without theme tokens in light and dark colors, at a 320px viewport, with text-spacing overrides, and with forced-colors emulation. Screen-reader use and actual 400% browser zoom have not been checked yet.