# Honest Prose

> Style rendered Markdown and plain HTML with one class, in any project, including text that is still streaming.

Source: https://ai.honestui.com/docs/prose

```tsx
"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ChevronRight } from "honestui/icons"

import { proseTailwind, proseTailwindClasses } from "@/lib/prose-tailwind"

import { Button } from "@/components/ui/button"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { useComponentPreviewPlaygroundPortal } from "@/components/docs/component/component-preview-source-context"

const controls = [
  { name: "--prose-size", label: "Size", min: 12, max: 20, step: 1, unit: "px" },
  { name: "--prose-leading", label: "Line height", min: 1.2, max: 2, step: 0.05, unit: "" },
  { name: "--prose-flow", label: "Block spacing", min: 4, max: 28, step: 2, unit: "px" },
  { name: "--prose-width", label: "Text width", min: 30, max: 90, step: 1, unit: "ch" },
  { name: "--prose-heading", label: "Heading size", min: 1, max: 2, step: 0.05, unit: "em" },
  { name: "--prose-tracking", label: "Letter spacing", min: -0.02, max: 0.12, step: 0.01, unit: "em" },
] as const

const radiusControl = { name: "--prose-radius", label: "Code corner radius", min: 0, max: 12, step: 1, unit: "px" } as const

type NumericName = (typeof controls)[number]["name"] | typeof radiusControl.name
type Values = Record<NumericName, number>

const presets: Record<string, Values> = {
  Chat: { "--prose-size": 14, "--prose-leading": 1.6, "--prose-flow": 12, "--prose-width": 60, "--prose-heading": 1.15, "--prose-tracking": 0, "--prose-radius": 4 },
  Document: { "--prose-size": 16, "--prose-leading": 1.7, "--prose-flow": 18, "--prose-width": 68, "--prose-heading": 1.5, "--prose-tracking": 0, "--prose-radius": 4 },
  Compact: { "--prose-size": 13, "--prose-leading": 1.45, "--prose-flow": 8, "--prose-width": 52, "--prose-heading": 1.15, "--prose-tracking": 0, "--prose-radius": 2 },
}

const colorControls = [
  { name: "--prose-color", label: "Text color" },
  { name: "--prose-muted", label: "Quote and caption color" },
  { name: "--prose-surface", label: "Code background" },
  { name: "--prose-rule", label: "Table and divider color" },
  { name: "--prose-rule-strong", label: "Quote rule color" },
] as const

type ColorName = (typeof colorControls)[number]["name"]
const emptyColors = Object.fromEntries(colorControls.map(({ name }) => [name, ""])) as Record<ColorName, string>
const validColor = (value: string) => value === "" || /^#[0-9a-f]{6}$/i.test(value)
const fieldClass = "min-w-0 rounded-[var(--hui-radius-2)] border border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] px-2 py-1.5 text-[var(--hui-color-foreground-base-primary)] focus-visible:[outline:var(--hui-focus-ring)]"

/** Controls live outside the rendered HTML, in the docs playground. */
export function ProseDemo() {
  const [values, setValues] = React.useState(presets.Chat)
  const [colors, setColors] = React.useState(emptyColors)
  const [codeFont, setCodeFont] = React.useState("")
  const [format, setFormat] = React.useState("css")
  const [copyStatus, setCopyStatus] = React.useState("")
  const id = React.useId()
  const playgroundPortal = useComponentPreviewPlaygroundPortal()

  const settings: Record<string, string> = Object.fromEntries(
    [...controls, radiusControl].map((control) => [control.name, `${values[control.name]}${control.unit}`])
  )
  for (const { name } of colorControls) {
    if (colors[name] && validColor(colors[name])) settings[name] = colors[name]
  }
  if (codeFont) settings["--prose-font-mono"] = codeFont
  const style = settings as React.CSSProperties
  const currentPreset = Object.entries(presets).find(([, preset]) =>
    Object.entries(preset).every(([name, value]) => values[name as NumericName] === value)
  )?.[0] ?? "Custom"
  const hasInvalidColor = Object.values(colors).some((color) => !validColor(color))
  const css = `.honest-prose {\n${Object.entries(settings).map(([name, value]) => `  ${name}: ${value};`).join("\n")}\n}`

  const tailwindClasses = proseTailwindClasses(settings)
  const exportText = format === "css" ? css : tailwindClasses
  const exportLabel = format === "css" ? "CSS" : "Tailwind classes"

  function reset() {
    setValues(presets.Chat)
    setColors(emptyColors)
    setCodeFont("")
    setCopyStatus("")
  }

  async function copyCss() {
    try {
      await navigator.clipboard.writeText(exportText)
      setCopyStatus(`${exportLabel} copied.`)
    } catch {
      setCopyStatus(`Copy failed. Select and copy the ${exportLabel} below.`)
    }
  }

  function slider(control: (typeof controls)[number] | typeof radiusControl) {
    return (
      <React.Fragment key={control.name}>
        <label htmlFor={`${id}${control.name}`}>{control.label}</label>
        <input
          id={`${id}${control.name}`}
          type="range"
          min={control.min}
          max={control.max}
          step={control.step}
          value={values[control.name]}
          onChange={(event) => { setValues({ ...values, [control.name]: Number(event.target.value) }); setCopyStatus("") }}
          className="w-full min-w-0 accent-[var(--hui-color-foreground-base-primary)]"
        />
        <output htmlFor={`${id}${control.name}`} className="font-mono tabular-nums">
          {values[control.name]}{control.unit}
        </output>
      </React.Fragment>
    )
  }

  return (
    <>
      {playgroundPortal && createPortal(
        <fieldset className="m-0 min-w-0 rounded-[var(--hui-radius-3)] border border-[var(--hui-color-border-base-primary)] p-[var(--hui-space-5)] text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [line-height:var(--hui-line-height-small)]">
          <legend className="px-[var(--hui-space-2)] font-medium">Customize Prose</legend>
          <div className="mb-5 flex flex-wrap items-center gap-3">
            <label htmlFor={`${id}-preset`}>Preset</label>
            <Select value={currentPreset} onValueChange={(value) => { if (value && presets[value]) setValues(presets[value]); setCopyStatus("") }}>
              <SelectTrigger id={`${id}-preset`} aria-label="Preset" size="sm" className="min-w-32"><SelectValue /></SelectTrigger>
              <SelectContent>
                {Object.keys(presets).map((name) => <SelectItem key={name} value={name}>{name}</SelectItem>)}
                <SelectItem value="Custom" disabled>Custom</SelectItem>
              </SelectContent>
            </Select>
            <label htmlFor={`${id}-format`}>Export</label>
            <Select items={{ css: "CSS", tailwind: "Tailwind" }} value={format} onValueChange={(value) => { if (value) setFormat(value); setCopyStatus("") }}>
              <SelectTrigger id={`${id}-format`} aria-label="Export" size="sm" className="min-w-32"><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="css">CSS</SelectItem>
                <SelectItem value="tailwind">Tailwind</SelectItem>
              </SelectContent>
            </Select>
            <Button type="button" variant="secondary" size="sm" onClick={reset}>Reset</Button>
            <Button type="button" variant="secondary" size="sm" disabled={hasInvalidColor} onClick={() => void copyCss()}>Copy {exportLabel}</Button>
            <p role="status" className="m-0 min-w-0 text-[var(--hui-color-foreground-base-secondary)] empty:hidden">{copyStatus}</p>
          </div>
          <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-3">
            {controls.map(slider)}
          </div>
          <details className="group/advanced mt-5 border-t border-[var(--hui-color-border-base-primary)] pt-4">
            <summary className="flex cursor-pointer list-none items-center gap-2 font-medium focus-visible:[outline:var(--hui-focus-ring)] [&::-webkit-details-marker]:hidden">
              <ChevronRight aria-hidden="true" focusable="false" className="size-4 shrink-0 group-open/advanced:rotate-90" />
              Advanced
            </summary>
            <p className="my-3 text-[var(--hui-color-foreground-base-secondary)]">Colors use six-digit hex values, such as #334155. Leave them blank to follow the theme. Check contrast after changing colors.</p>
            <div className="grid gap-3 sm:grid-cols-2">
              {colorControls.map(({ name, label }) => {
                const invalid = !validColor(colors[name])
                return (
                  <div key={name} className="flex min-w-0 flex-col gap-1.5">
                    <label htmlFor={`${id}${name}`}>{label}</label>
                    <input id={`${id}${name}`} value={colors[name]} placeholder="Theme default" spellCheck={false} className={fieldClass} aria-invalid={invalid || undefined} aria-describedby={invalid ? `${id}${name}-error` : undefined} onChange={(event) => { setColors({ ...colors, [name]: event.target.value }); setCopyStatus("") }} />
                    {invalid && <p id={`${id}${name}-error`} className="m-0">Enter a color like #334155, or leave it blank.</p>}
                  </div>
                )
              })}
              <div className="flex min-w-0 flex-col gap-1.5">
                <label htmlFor={`${id}-font`}>Code font</label>
                <Select items={{ theme: "Theme default", "ui-monospace, monospace": "System monospace", "Menlo, monospace": "Menlo", "Consolas, monospace": "Consolas" }} value={codeFont || "theme"} onValueChange={(value) => { setCodeFont(value === "theme" ? "" : value ?? ""); setCopyStatus("") }}>
                  <SelectTrigger id={`${id}-font`} aria-label="Code font" size="sm" className="w-full"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="theme">Theme default</SelectItem>
                    <SelectItem value="ui-monospace, monospace">System monospace</SelectItem>
                    <SelectItem value="Menlo, monospace">Menlo</SelectItem>
                    <SelectItem value="Consolas, monospace">Consolas</SelectItem>
                  </SelectContent>
                </Select>
              </div>
            </div>
            <div className="mt-4 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3">{slider(radiusControl)}</div>
          </details>
          <details className="group/css mt-4">
            <summary className="flex cursor-pointer list-none items-center gap-2 focus-visible:[outline:var(--hui-focus-ring)] [&::-webkit-details-marker]:hidden">
              <ChevronRight aria-hidden="true" focusable="false" className="size-4 shrink-0 group-open/css:rotate-90" />
              View {exportLabel}
            </summary>
            <pre tabIndex={0} aria-label={`Custom Prose ${exportLabel}`} className="mt-3 overflow-x-auto rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-secondary)] p-3 font-mono focus-visible:[outline:var(--hui-focus-ring)]"><code>{exportText}</code></pre>
          </details>
        </fieldset>,
        playgroundPortal
      )}

      <div className="grid min-h-[440px] w-full min-w-0 place-items-center p-4 sm:p-6">
        <article className={`${format === "css" ? "honest-prose" : proseTailwind} w-full min-w-0`} style={style} data-slot="prose-demo">
          <h2>How Prose spaces text</h2>
          <p>
            Every block takes its space from <code>--prose-flow</code>, as a margin above it.{" "}
            <strong>Nothing</strong> is spaced from below, so a block that arrives later does not
            move the ones before it.
          </p>
          <ul>
            <li>A heading sits closer to the block under it than to the one above.</li>
            <li>
              A list item is tighter than a paragraph.
              <ul>
                <li>A nested list changes its marker.</li>
              </ul>
            </li>
          </ul>
          <blockquote>
            <p>A quote is set in the muted color, behind a rule.</p>
          </blockquote>
          <table>
            <thead>
              <tr>
                <th>Variable</th>
                <th>Sets</th>
              </tr>
            </thead>
            <tbody>
              <tr>
                <td>
                  <code>--prose-size</code>
                </td>
                <td>Body text size</td>
              </tr>
              <tr>
                <td>
                  <code>--prose-leading</code>
                </td>
                <td>Body line height</td>
              </tr>
            </tbody>
          </table>
        </article>
      </div>
    </>
  )
}

```

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 [#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](/docs/components/markdown) and [Streaming Text](/docs/components/streaming-text) use it for their text.

## Installation [#installation]

### Tailwind v4, without a separate stylesheet [#tailwind-v4-without-a-separate-stylesheet]

<CliBlock commands="[&#x22;@honestai/prose-tailwind&#x22;]" />

This copies `lib/prose-tailwind.ts`, with no package or stylesheet dependencies. Your project needs Tailwind v4 already configured.

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

### lib/prose-tailwind.ts

```ts
// 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 [#standalone-css]


  
    Add the file.
  

  <CliBlock commands="[&#x22;@honestai/prose&#x22;]" />

  Or copy it into `styles/prose.css` yourself:

  ### styles/prose.css

```css
/*
 * 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 it in your global stylesheet. With Tailwind, put it after the Tailwind import.
  

  ```css
  @import "tailwindcss";
  @import "../styles/prose.css";
  ```


## Usage [#usage]

```html
<article class="honest-prose">
  <h2>Release checklist</h2>
  <p>The export fix is ready.</p>
</article>
```

With a Markdown renderer, wrap its output:

```tsx
<div className="honest-prose">
  <ReactMarkdown>{text}</ReactMarkdown>
</div>
```

## Variables [#variables]

Three variables do most of the work. Set them on the `.honest-prose` element or on anything around it.

| Variable          | Sets                          | Default                                     |
| ----------------- | ----------------------------- | ------------------------------------------- |
| `--prose-size`    | Body text size                | `--hui-font-size-regular`, or `1em`         |
| `--prose-leading` | Body line height, as a number | `1.6`                                       |
| `--prose-flow`    | Space between blocks          | `--hui-space-4`, or `0.85em`                |
| `--prose-width`   | Maximum text width            | None; the surrounding layout sets the width |

The rest are for matching a theme:

| Variable              | Sets                                     | Default                                                              |
| --------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
| `--prose-heading`     | Size of first- and second-level headings | `1.143em`                                                            |
| `--prose-tracking`    | Body letter spacing                      | `--hui-letter-spacing-regular`, or `normal`                          |
| `--prose-color`       | Text                                     | `--hui-color-foreground-base-primary`, or the current color          |
| `--prose-muted`       | Quotes and captions                      | `--hui-color-foreground-base-secondary`, or 72% of the current color |
| `--prose-surface`     | Behind inline code and plain code blocks | `--hui-color-background-base-secondary`, or 7% of the current color  |
| `--prose-rule`        | Table lines and horizontal rules         | `--hui-color-border-base-primary`, or 18% of the current color       |
| `--prose-rule-strong` | The rule beside a quote                  | `--hui-color-border-base-secondary`, or 34% of the current color     |
| `--prose-font-mono`   | Code                                     | `--hui-font-mono`, or the system monospace font                      |
| `--prose-radius`      | Corners of inline code                   | `--hui-radius-1`, or `0.25em`                                        |

Prose reads these variables and never sets them. A value from a class, an inline style, or an ancestor always applies:

```tsx
<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 [#behavior]

### Sizes [#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 [#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 [#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](/docs/components/code-view) carries this class itself.

### Wide tables [#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 [#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 [#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.

## What it styles [#what-it-styles]

| Element                                                                        | Result                                                              |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| `h1` to `h6`                                                                   | Semibold, balanced wrapping, closer to the block below              |
| `p`, `ul`, `ol`, `dl`, `blockquote`, `pre`, `table`, `figure`, `details`, `hr` | Spaced by `--prose-flow`                                            |
| `ul`, `ol`                                                                     | Disc and decimal markers, a circle for a nested list, tighter items |
| A task list from GitHub Flavored Markdown                                      | No bullets, the checkbox in their place                             |
| `a`                                                                            | Underlined, in the text color                                       |
| `strong`, `b`                                                                  | Semibold                                                            |
| `code`, `kbd`, `samp`                                                          | Monospace, on the surface color when inline                         |
| `pre`                                                                          | The surface color, padded, scrolling sideways                       |
| `table`                                                                        | Smaller text, a line above each body row                            |
| `img`, `video`                                                                 | No wider than the container                                         |
