Long-form text you render from Markdown: headings, lists, quotes, code and tables in the library's type, at a reading length.

A report

Prose gives the HTML inside it the library's type and spacing: headings by the heading steps, paragraphs at the body size with a 1.6 line height, lists, quotes, code, tables, figures and rules. Running text stops at 70 characters, a comfortable line; tables, code and figures take the whole width. Blocks sit 24px apart, a heading 32px under what came before it and 8px over what follows it, so a heading reads with its section. Here a report you write is rendered from Markdown by react-markdown in a server component, so the page sends no JavaScript for it.

Example

October

You spent $3,284.10 in October, $212.00 less than in September. Most of the drop came from eating out, which fell by a third.

Where it went

  1. Rent, $1,450.00, the same as every month.
  2. Groceries, $612.40, up from $588.15.
  3. Eating out, $286.90, down from $431.00.

A month with fewer evenings out is a month with more room for the travel fund.

See Budgets to change a limit, or export the month with snapshot export --month 2026-10.

date,category,amount
2026-10-01,Rent,-1450.00
2026-10-04,Groceries,-82.15
Follows the page theme and corners
import { Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { REPORT } from './prose-fixtures';
import { OwnMarkdown } from './prose-markdown';

/** A server component: a report you write, rendered from Markdown into Prose, with no client JavaScript. */
export default function ProseReport(): ReactElement {
  return (
    <Prose as="article" style={{ width: '100%' }}>
      <OwnMarkdown>{REPORT}</OwnMarkdown>
    </Prose>
  );
}

Rendering Markdown safely

Render Markdown on the server, with react-markdown and remark-gfm for tables, in a server component. Text a model writes is not yours to trust: it can hold a link to anywhere, a picture from anywhere and HTML. So for model output, pass skipHtml, which drops every tag, and give react-markdown a components map that turns a link into its text, span.prose-unlinked, and an image into its alt text, span.prose-image. The reader is never sent to an address and never shown a picture from one; a paragraph that held a link holds its words and reads the same, since Prose lays nothing out by a or img. A link within the page, such as a footnote's mark and its way back, stays a link, since it sends the reader nowhere new. The image's span starts with a visually hidden "Image:", so a screen reader says what the words are, and an image with no alt text renders nothing. The same map puts a table in a ProseScroll named from its column headers, so a page with several tables names each one, and makes a code block a named region with tabIndex={0}, so it scrolls by keys in every browser. Your own content, such as a help article, keeps its links: give it the map without a and img.

Example

Read the full report at https://example.com/reports/2026/october?view=all.

Image: Spending by category in October track()

Follows the page theme and corners
import { Prose, ProseScroll } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import Markdown, { type Components, type Options } from 'react-markdown';
import remarkGfm from 'remark-gfm';

/** The words the map names a table and a code block with, and an image's alt text with, in the page's language. */
export interface MarkdownLabels {
  /** Starts a table's name, before its column headers: "Table". */
  table: string;
  /** A code block's name, before its language when it has one: "Code". */
  code: string;
  /** Read before an image's alt text, and not shown: "Image:". */
  image: string;
}

const ENGLISH: MarkdownLabels = { table: 'Table', code: 'Code', image: 'Image:' };

/** The part of the HTML tree react-markdown hands a component as `node`, as far as the map reads it. */
interface Node {
  type: string;
  tagName?: string;
  value?: string;
  properties?: { className?: unknown };
  children?: Node[];
}

/** The text a node holds. */
const textOf = (node: Node): string =>
  node.type === 'text' ? (node.value ?? '') : (node.children ?? []).map(textOf).join('');

/** The elements among a node's children with this tag. */
const childrenNamed = (node: Node | undefined, tag: string): Node[] =>
  (node?.children ?? []).filter((child) => child.tagName === tag);

/** A table's name from its column headers, so a page with several regions tells them apart. */
function tableName(node: Node | undefined, labels: MarkdownLabels): string {
  const [head] = childrenNamed(node, 'thead');
  const [row] = childrenNamed(head, 'tr');
  const headers = childrenNamed(row, 'th').map((cell) => textOf(cell).trim());
  return headers.length > 0 ? `${labels.table}: ${headers.join(', ')}` : labels.table;
}

/** A code block's name: its language when the fence gives one, and its first line. */
function codeName(node: Node | undefined, labels: MarkdownLabels): string {
  const [code] = childrenNamed(node, 'code');
  const classes = code?.properties?.className;
  const language = Array.isArray(classes)
    ? classes.find((name): name is string => typeof name === 'string' && name.startsWith('language-'))
    : undefined;
  // Its first line tells two blocks apart, as a table's headers do.
  const first = (code ? textOf(code) : '').trim().split('\n')[0] ?? '';
  const line = first.length > 40 ? `${first.slice(0, 40)}...` : first;
  const kind = language ? `${labels.code}, ${language.slice('language-'.length)}` : labels.code;
  return line ? `${kind}: ${line}` : kind;
}

/** The parts both maps share: a table scrolls in a region named by its headers, a code block in one of its own. */
const scrolling = (labels: MarkdownLabels): Components => ({
  table: ({ node, children }) => (
    <ProseScroll aria-label={tableName(node, labels)}>
      <table>{children}</table>
    </ProseScroll>
  ),
  pre: ({ node, children }) => (
    // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- a code block that scrolls must take focus to scroll by keyboard
    <pre role="region" aria-label={codeName(node, labels)} tabIndex={0}>
      {children}
    </pre>
  ),
});

/**
 * The map for Markdown a model writes. A link becomes its text and an image its alt text, so the reader is never
 * sent to an address or shown a picture from one; a link within the page, such as a footnote's, stays a link.
 */
export const modelOutput = (labels: MarkdownLabels = ENGLISH): Components => ({
  ...scrolling(labels),
  a: ({ children, ...link }) => {
    // react-markdown's tree node is not an attribute of the link.
    delete link.node;
    return link.href?.startsWith('#') ? (
      <a {...link}>{children}</a>
    ) : (
      <span className="prose-unlinked">{children}</span>
    );
  },
  img: ({ alt }) =>
    alt ? (
      <span className="prose-image">
        <span className="visually-hidden">{labels.image} </span>
        {alt}
      </span>
    ) : null,
});

/** The map for your own content, such as a help article: its links stay links. */
export const ownContent = (labels: MarkdownLabels = ENGLISH): Components => scrolling(labels);

interface MarkdownProps {
  children: string;
  labels?: MarkdownLabels;
  /** Passed to remark-rehype, such as the footnotes' label in the page's language. */
  remarkRehypeOptions?: Options['remarkRehypeOptions'];
}

/** Model output, rendered on the server: GitHub's tables and footnotes, and no HTML at all. */
export function ModelMarkdown({ children, labels, remarkRehypeOptions }: MarkdownProps): ReactElement {
  return (
    <Markdown
      remarkPlugins={[remarkGfm]}
      remarkRehypeOptions={remarkRehypeOptions}
      skipHtml
      components={modelOutput(labels)}
    >
      {children}
    </Markdown>
  );
}

/** Your own Markdown, rendered on the server, with its links. */
export function OwnMarkdown({ children, labels, remarkRehypeOptions }: MarkdownProps): ReactElement {
  return (
    <Markdown
      remarkPlugins={[remarkGfm]}
      remarkRehypeOptions={remarkRehypeOptions}
      components={ownContent(labels)}
    >
      {children}
    </Markdown>
  );
}

const SNIPPET = `Read [the full report](https://example.com/report) at https://example.com/reports/2026/october?view=all.

![Spending by category in October](https://example.com/october.png) <script>track()</script>`;

/** A server component: what a model wrote, with its link, its address and its image turned into text. */
export default function ProseMarkdown(): ReactElement {
  return (
    <Prose style={{ width: '100%' }}>
      <ModelMarkdown>{SNIPPET}</ModelMarkdown>
    </Prose>
  );
}

HTML from a CMS is not Markdown. Sanitize it first, with a library such as DOMPurify or sanitize-html, then pass it to the element as React's raw HTML attribute, which Prose passes on like any other. Prose never parses or injects HTML, and has no prop that would.

Model output

A monthly report as a model wrote it: a link, a bare address, two images, some HTML and a footnote, rendered with skipHtml and the map. The link and the address are plain text, the address breaking anywhere so it never pushes a phone sideways; the image's alt text sits in a small slate box; the image with no alt text is not drawn; the HTML is gone; the six-column table scrolls in its own region; and the footnote's mark links to its note, under a rule. GFM writes a "Footnotes" heading over the notes for screen readers alone, which Prose hides as visually hidden text.

Example

October at a glance

You spent $3,284.10 in October. Most of the drop came from eating out, while groceries rose a little.1

See your transactions for every entry, or https://example.com/reports/2026/october/spending-by-category?view=table&compare=september for the full table.

Image: Spending by category in October, rent first

The chart above is drawn from the same figures as the table below, and this bold was written as HTML.

CategorySeptemberOctoberChangeShareBudget
Rent$1,450.00$1,450.00$0.0044%$1,450.00
Groceries$588.15$612.40+$24.2519%$650.00
Eating out$431.00$286.90-$144.109%$300.00
Transport$214.60$198.30-$16.306%$250.00
Utilities$176.20$181.75+$5.556%$200.00

Footnotes

  1. Refunds are counted in the month they arrive. ↩

Follows the page theme and corners
import { Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { MODEL_OUTPUT } from './prose-fixtures';
import { ModelMarkdown } from './prose-markdown';

/**
 * A server component: a report a model wrote, rendered with skipHtml and the model output map. The link reads as
 * the text around it, the bare address breaks anywhere, the image shows its alt text in a small box, the one
 * with no alt text is not drawn, and the HTML never reaches the page.
 */
export default function ProseModelOutput(): ReactElement {
  return (
    <Prose as="article" style={{ width: '100%' }}>
      <ModelMarkdown>{MODEL_OUTPUT}</ModelMarkdown>
    </Prose>
  );
}

Copy an answer

A copy button under an answer, in the answer's footer, outside the Prose box. While an answer streams in, an app marks its body aria-busy="true", and a screen reader may hold back what changes inside a busy part, so the button's polite "Copied" goes in the footer, which is never busy. The button copies the answer's text as your app holds it, not the drawn HTML.

Example

Spending on food is down 12% this month, mostly from fewer restaurant visits. Groceries held steady at $410.

Follows the page theme and corners
'use client';

import { CopyButton, Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const ANSWER =
  'Spending on food is down 12% this month, mostly from fewer restaurant visits. Groceries held steady at $410.';

export default function CopyButtonAnswer(): ReactElement {
  // While an answer streams in, the app marks its body aria-busy="true" and sets it false once it has finished.
  const isStreaming = false;
  return (
    <article aria-label="Answer" className="stack" style={{ width: '100%', maxWidth: '36rem' }}>
      <Prose aria-busy={isStreaming}>
        <p>{ANSWER}</p>
      </Prose>
      {/* The footer is never busy, so the copy button's status in it is always heard. */}
      <footer className="row">
        <CopyButton value={ANSWER} labels={{ copy: 'Copy answer' }} />
      </footer>
    </article>
  );
}

Wide tables

A table with four or more columns of figures is wider than a phone. Put it in a ProseScroll: a region that scrolls sideways inside the article, so the page never does, and that takes focus, so a keyboard reader can scroll it with the arrow keys. Its name is required, since a region a screen reader lists needs one: name the table, such as "October by category, table". It is always a tab stop, whether the table overflows or not, since it cannot know without measuring; so wrap only tables wider than three columns, and give each a name of its own. The table keeps its rows and columns. A table outside one keeps to the width it is given by breaking its cells anywhere, which keeps the page whole but makes figures hard to read, so wrap every wide table.

Example

Each category against September and its budget, with eating out down the most.

CategorySeptemberOctoberChangeShareBudget
Rent$1,450.00$1,450.00$0.0044%$1,450.00
Groceries$588.15$612.40+$24.2519%$650.00
Eating out$431.00$286.90-$144.109%$300.00
Transport$214.60$198.30-$16.306%$250.00
Utilities$176.20$181.75+$5.556%$200.00
Follows the page theme and corners
import { Prose, ProseScroll } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const ROWS = [
  ['Rent', '$1,450.00', '$1,450.00', '$0.00', '44%', '$1,450.00'],
  ['Groceries', '$588.15', '$612.40', '+$24.25', '19%', '$650.00'],
  ['Eating out', '$431.00', '$286.90', '-$144.10', '9%', '$300.00'],
  ['Transport', '$214.60', '$198.30', '-$16.30', '6%', '$250.00'],
  ['Utilities', '$176.20', '$181.75', '+$5.55', '6%', '$200.00'],
];

/** Six columns of figures in a ProseScroll: on a phone the table scrolls inside its named region, not the page. */
export default function ProseWideTable(): ReactElement {
  return (
    <Prose style={{ width: '100%' }}>
      <p>
        Each category against September and its budget, with <mark>eating out</mark> down the most.
      </p>
      <ProseScroll aria-label="October by category, table">
        <table>
          <thead>
            <tr>
              <th scope="col">Category</th>
              {['September', 'October', 'Change', 'Share', 'Budget'].map((name) => (
                <th key={name} scope="col" style={{ textAlign: 'end' }}>
                  {name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {ROWS.map(([category = '', ...figures]) => (
              <tr key={category}>
                <th scope="row">{category}</th>
                {figures.map((figure, index) => (
                  <td key={index} style={{ textAlign: 'end' }}>
                    {figure}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </ProseScroll>
    </Prose>
  );
}

Components in an article

A Bar chart and a Badge inside an article, with no not-prose. Prose spaces the chart as a block of the article, 24px from the paragraphs around it, and leaves everything inside it, its figure, its rows and its table, as it is anywhere.

Example

Where October went New

Rent was most of the month, as it is every month, and eating out fell by a third.

Refunds are counted in the month they arrive.

Follows the page theme and corners
import { BarChart, type BarValue } from '@snapshot-ui/charts';
import { Badge, Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

/** An amount as the app formats it: the number to draw and the string to say it. */
const money = (value: number): BarValue => ({ value, valueLabel: `$${value.toFixed(2)}` });

/**
 * A chart and a badge inside an article, with no not-prose: prose leaves every library component as it is
 * anywhere, its figure, its rows and its table included, and spaces the chart as a block of the article.
 */
export default function ProseWithChart(): ReactElement {
  return (
    <Prose as="article" style={{ width: '100%' }}>
      <h2>
        Where October went <Badge tone="info">New</Badge>
      </h2>
      <p>Rent was most of the month, as it is every month, and eating out fell by a third.</p>
      <BarChart
        aria-label="Spending by category, October"
        orientation="horizontal"
        categories={['Rent', 'Groceries', 'Eating out', 'Transport', 'Utilities']}
        series={[{ label: 'October', values: [1450, 612.4, 286.9, 198.3, 181.75].map(money) }]}
      />
      <p>Refunds are counted in the month they arrive.</p>
    </Prose>
  );
}

In a card and a page column

In a Card, Prose is one part of the body: the card's padding is the article's inset, since the first and last blocks carry no outer margin, and a code block or an image takes the card's concentric corner wherever it falls. Start it at a paragraph or an h2, and leave the title to the card. On an article page, put Prose as="article" in the PageColumn under a PageHeader: text keeps its measure from the column's start edge, under the title, and tables and figures take the column's width.

Example

Your week

You stayed under your groceries budget for the third week running. Eating out is at $96.40 of $75.00, so the weekend is the place to save.

  1. Plan two dinners at home before Friday.
  2. Move $20.00 to the travel fund on payday, before it is spent.
Travel fund: $1,240.00 of $2,000.00
Follows the page theme and corners
import { Card, Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { COACHING_NOTE } from './prose-fixtures';
import { ModelMarkdown } from './prose-markdown';

/**
 * A weekly note in a Card: the card's padding is the article's inset, an h2 starts it, and the code block at its
 * foot takes the card's concentric corner.
 */
export default function ProseCard(): ReactElement {
  return (
    <Card style={{ maxWidth: 560, marginInline: 'auto', width: '100%' }}>
      <Prose>
        <ModelMarkdown>{COACHING_NOTE}</ModelMarkdown>
      </Prose>
    </Card>
  );
}

Persian

Under lang="fa" a list's markers, a quote's rule and an indent sit on the right, ordered lists count in Persian digits, headings drop their tracking, which would pull a cursive script apart, and emphasis is weight 500 and upright, since Vazirmatn has no italic. Code, inline and in blocks, stays left to right, and so do figures. Sofia Sans has no Persian letters, so load Vazirmatn and put it first in the stack under lang="fa", as the installation page shows. A renderer that mixes Persian and English paragraphs writes dir="auto" on each; Prose sets no direction. An amount written in a Persian sentence, such as $1,250.00, is drawn with its sign on the right, "1,250.00$", since after Persian letters the bidirectional algorithm reads the sign with them: wrap each amount in a left-to-right isolate, U+2066 before it and U+2069 after it, as the guide's fixture does. GFM labels footnotes in English; pass the label in the page's language through react-markdown's remarkRehypeOptions, such as { footnoteLabel: 'پانویس‌ها', footnoteBackLabel: 'بازگشت به متن' }.

Example

راهنمای هدف‌ها

هدف، مبلغی است که برای چیزی کنار می‌گذارید: یک سفر، صندوق اضطراری یا یک لپ‌تاپ تازه. هر ماه سهمی از درآمدتان به آن می‌رسد و پیشرفتش را هر هفته می‌بینید.

ساختن یک هدف

  1. در صفحه‌ی هدف‌ها، هدف تازه را بزنید.
  2. نام هدف و مبلغ آن را بنویسید، مثلاً «سفر بهاری».
  3. تاریخی را که می‌خواهید به هدف برسید انتخاب کنید.
  • هدف‌ها را از پرونده‌ی goals.csv هم می‌توانید وارد کنید.
  • مبلغ‌ها از چپ به راست نوشته می‌شوند، مانند ⁦$1,250.00⁩ در ماه.

هدفی که تاریخ دارد، زودتر به دست می‌آید.

برای جزئیات بیشتر، راهنمای بودجه‌ها را ببینید.1

پانویس‌ها

  1. بودجه‌ها هر ماه از نو شروع می‌شوند. ↩

Follows the page theme and corners
import { Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { GOALS_GUIDE_FA } from './prose-fixtures';
import { OwnMarkdown, type MarkdownLabels } from './prose-markdown';

/** The map's names in Persian, and the footnotes' label and back link, which GFM writes in English otherwise. */
const PERSIAN: MarkdownLabels = { table: 'جدول', code: 'کد', image: 'تصویر:' };
const FOOTNOTES = { footnoteLabel: 'پانویس‌ها', footnoteBackLabel: 'بازگشت به متن' };

/**
 * The goals guide in Persian, in Vazirmatn: markers and the quote's rule on the right, Persian digits, emphasis
 * by weight, and code and figures left to right.
 */
export default function ProsePersian(): ReactElement {
  return (
    <div lang="fa" dir="rtl" style={{ width: '100%' }}>
      <Prose as="article">
        <OwnMarkdown labels={PERSIAN} remarkRehypeOptions={FOOTNOTES}>
          {GOALS_GUIDE_FA}
        </OwnMarkdown>
      </Prose>
    </div>
  );
}

Usage

  • Prose and ProseScroll are static. They render in a server component and ship no JavaScript, so a report rendered on the server costs the page nothing. They render inside a client component unchanged, for text that streams in.
  • Put the prose class on any element, or use Prose, which renders a div, an article or a section through as. Prose never reaches a library component: its rules sit in a layer under every component's, and every component inside the box, a chart, a table, a trail or a card with your text in it, takes for whatever its own rules leave unset what it would take outside prose, so it draws as it does anywhere. To leave a subtree of your own markup alone, give it the not-prose class.
  • Prose does not nest inside what it leaves alone: a Prose inside a not-prose subtree, or inside a library component that sits inside another prose box, is unstyled. A Prose in a Card or a page column on its own is styled as usual.
  • Text stops at --prose-measure, 70ch of the body's text, one line length for every block, headings included. Set it on the prose element or above it: none for a narrow column, or a smaller length. Headings take --prose-scroll-margin above them when a link jumps to one; set it to your sticky header's height.
  • A Markdown # is an h1, and a page has one. Shift the headings down one level in your renderer, such as react-markdown's components mapping h1 to h2, and the look follows the element.
  • Load Sofia Sans's italic as well as its roman, as the installation page shows; without it, emphasis is upright.
  • Tailwind Typography also names its class prose, and its rules sit in Tailwind's utilities layer, which comes after the components layer. So they match every prose element, the library's Prose included, and win over the library's rules. In an app that uses both, give Typography a class of its own:
@plugin "@tailwindcss/typography" {
  className: "tw-prose";
}
import { Prose, ProseScroll } from '@snapshot-ui/react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

export function MonthlyReport({ markdown }: { markdown: string }) {
  return (
    <Prose as="article">
      <Markdown
        remarkPlugins={[remarkGfm]}
        skipHtml
        components={{
          // A footnote's link stays, within the page; any other becomes its text.
          a: ({ node, children, ...link }) =>
            link.href?.startsWith('#') ? (
              <a {...link}>{children}</a>
            ) : (
              <span className="prose-unlinked">{children}</span>
            ),
          img: ({ alt }) =>
            alt ? (
              <span className="prose-image">
                <span className="visually-hidden">Image: </span>
                {alt}
              </span>
            ) : null,
          table: ({ children }) => (
            <ProseScroll aria-label="Spending by category, table">
              <table>{children}</table>
            </ProseScroll>
          ),
          pre: ({ children }) => (
            <pre role="region" aria-label="Code" tabIndex={0}>
              {children}
            </pre>
          ),
        }}
      >
        {markdown}
      </Markdown>
    </Prose>
  );
}

Props

Both take className (merged after their own class), ref and their other HTML attributes.

Prose

as
"article" | "div" | "section"

The element to render. article for a piece that stands alone, such as a help article or a monthly report; section for a part of a page with its own heading, which is a region landmark only when it is named; div for text inside something else, such as a card's body.

Default: 'div'

className
string

ProseScroll

aria-label
string

A name for assistive technology. Give it, or aria-labelledby, where no label or text is shown.

aria-labelledby
string

The id of an element that holds the name, such as a label shown elsewhere.

className
string

Accessibility

The elements are yours, and Prose adds none: a screen reader reads your headings, lists, quotes and tables as they are. Prose as="article" is not a landmark; as="section" is a region landmark only when you name it. A ProseScroll is a named region that takes focus, so a keyboard reader can scroll a wide table, and its focus ring shows where focus is; a code block mapped as a named region with tabIndex={0} scrolls the same way. A ProseScroll is a tab stop even when its table fits, so wrap only wide tables. A neutralized link is text: no tab stop, no role and no underline, so nothing looks like a link that goes nowhere. Real links are underlined, so they are never told by color alone.

Design rules

What keeps long-form text in the system.

Render Markdown on the server, and pass skipHtml for anything a model wrote.

Turn every link and image in model output into its text.

Put every table wider than three columns in a ProseScroll, and name it.

Start Markdown inside a page at an h2.

Never render HTML you have not sanitized.

Never center an article: text keeps to the start edge.

Never draw a neutralized link as a link.

esc

↑ ↓ to move↵ to open