# Prose

Status: stable. Added in Components 2.0.0. Updated in Components 2.16.0, 20 Oct 2026.

Typesets rich HTML from a CMS, Markdown, or user content.

Prose renders the core [`.prose` class](https://ticketsolutionsptyltd.github.io/roadie/foundations/prose.md), which styles the
bare headings, paragraphs, lists, tables, code, quotes, rules, figures, and keys
inside it. It sets the rhythm, a 65ch measure, and every colour from Roadie
tokens.

## Import

```tsx
import { Prose } from '@oztix/roadie-components/prose'
```

## Examples

### Default

```tsx
<Prose>
  <h2>Ochre Kite Weekender 2026</h2>
  <p>
    Gates open at <strong>11am</strong> on Saturday. Bring a printed or{' '}
    <a href='#'>mobile ticket</a>, and show it at the north gate.
  </p>
  <h3>What to bring</h3>
  <ul>
    <li>Photo ID for the bar</li>
    <li>Sunscreen and a refillable bottle</li>
  </ul>
  <p>
    Set <code>data-ticket</code> on the scanner link to open the right wallet.
  </p>
</Prose>
```

### Sizes

`sm` sets 14px body text, headings one step below the UI heading styles, and
a tighter rhythm, for dense content such as an FAQ or a sidebar. `md` is the
default, with the UI heading styles, for content inside a page such as an
event description. `lg` sets larger body text with the prose heading styles,
for editorial pages.

```tsx
<Prose size='sm'>
  <h3>Can I change the name on my ticket?</h3>
  <p>Yes, up to 48 hours before doors. Open the ticket and choose Transfer.</p>
</Prose>
<Prose size='md'>
  <h3>Getting there</h3>
  <p>Catch the festival shuttle from the city, then walk the marked path.</p>
</Prose>
<Prose size='lg'>
  <h3>A weekend at Jumbuck Orchard Park</h3>
  <p>Three stages, two nights, and the longest bar in the valley.</p>
</Prose>
```

Each size only sets `--prose-*` variables, so a class such as
`[--prose-flow:2em]` on Prose adjusts it further. The
[variables table](https://ticketsolutionsptyltd.github.io/roadie/foundations/prose.md#variables) lists them all.

### Custom element

Use `render` to draw Prose as a semantic element. The deprecated `as` prop
still works the same way.

```tsx
<Prose render={<article />}>
  <h2>Conditions of entry</h2>
  <p>
    This renders as an <code>&lt;article&gt;</code> element instead of a{' '}
    <code>&lt;div&gt;</code>.
  </p>
</Prose>
```

### Tables

Prose wraps each bare table in a `.prose-scroll`, which scrolls sideways
instead of overflowing. Each column stays at least 9em wide, so a table that
can't fit scrolls rather than squeezing its text. While a table overflows, its
scroller is a named region in the tab order, so a keyboard user can reach it
with Tab and scroll it with the arrow keys.

```tsx
<Prose>
  <h3 id='set-times'>Set times</h3>
  <table>
    <thead>
      <tr>
        <th>Session</th>
        <th>Stage</th>
        <th>Doors</th>
        <th>Price</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Feathered Anchor Sessions</td>
        <td>Main</td>
        <td>7:30pm</td>
        <td>$49.00</td>
      </tr>
      <tr>
        <td>Lampshade Disco</td>
        <td>Tent</td>
        <td>10:00pm</td>
        <td>$35.00</td>
      </tr>
    </tbody>
  </table>
</Prose>
```

Prose finds the tables written as elements in its children, and the tables in
HTML it renders, such as CMS output:

```tsx
<Prose dangerouslySetInnerHTML={{ __html: page.body }} />
```

A table a component renders inside Prose isn't wrapped, because React owns it.
Wrap that table in `.prose-scroll` yourself, and Prose still makes the scroller
a region. A table already in `.prose-scroll`, `.not-prose`, or
`data-not-prose` stays as it is.

### Opting out

Add `.not-prose` or `data-not-prose` to an element inside Prose to keep the
styles away from it and everything inside it. The
[prose foundation](https://ticketsolutionsptyltd.github.io/roadie/foundations/prose.md#opting-out) covers how an escaped child
keeps its place in the rhythm.

A button or link button that renders an `<a>` inside Prose gets the prose
underline. Wrap it in `.not-prose` to keep its own styles.

```tsx
<Prose>
  <p>Tickets go on sale Friday at 9am.</p>
  <div className='not-prose flex flex-wrap gap-2'>
    <Button href='#' intent='accent' emphasis='strong'>
      Buy tickets
    </Button>
    <Button href='#'>Set a reminder</Button>
  </div>
</Prose>
```

## Guidelines

- Use Prose for rendered content, such as CMS output, Markdown, and user HTML.
  For app UI, use the display and text utilities directly.
- A Prose inside a `.not-prose` element stays unstyled, so put the escape
  around the parts that need it, not around a region that also holds Prose.
- Use `render` rather than `as` to set the element; `as` is removed in v3.
- Give a table a caption, or put it under a heading with an `id`, so its
  region has a name that says what the table holds.

## Accessibility

- A table's scroller gets `tabIndex={0}` and `role="region"` only while the
  table overflows, and Prose checks again whenever its size changes. A table
  that fits adds no tab stop.
- The region is named by the table's caption. Without one, it points
  `aria-labelledby` at the nearest heading before it that has an `id`. A
  later table under the same heading is named "Set times, table 2", and a
  table with no heading before it is "Table", then "Table 2".
- The focused scroller shows the
  [`is-focusable`](https://ticketsolutionsptyltd.github.io/roadie/foundations/interactions.md#is-focusable) ring.
- The server renders the scroller without a tab stop, and Prose adds it after
  hydration, so the markup matches. Tables in rendered HTML are wrapped after
  hydration too.

## Hooks

### useScrollRegion

```tsx
import { useScrollRegion } from '@oztix/roadie-components/scroll-region'

function useScrollRegion(ref: RefObject<HTMLElement | null>): void
```

Gives a scroll container outside Prose the same keyboard access as a Prose
table. Put the ref on the element that scrolls, and add `is-focusable` for the
ring.

```tsx
function RefundTable() {
  const ref = useRef<HTMLDivElement>(null)
  useScrollRegion(ref)
  return (
    <div ref={ref} className='overflow-x-auto is-focusable'>
      <table>…</table>
    </div>
  )
}
```

It returns nothing, and sets these attributes while the content overflows:

| Attribute                         | Value                                                         |
| --------------------------------- | ------------------------------------------------------------- |
| `tabindex`                        | `0`, unless the element already has a `tabindex`              |
| `role`                            | `region`, unless the element already has a role               |
| `aria-label` or `aria-labelledby` | The name Prose would give, unless the element already has one |

## API reference

### Prose

Prose container for long-form/rich content (CMS output, markdown, user HTML). Renders the core `.prose` class, which styles the nested HTML. Wraps each bare table in a `.prose-scroll`, which keyboard users can reach and scroll while the table overflows.

```tsx
import { Prose } from '@oztix/roadie-components/prose'
```

- `as`: `ElementType`. Deprecated: Use `render` instead. `as` will be removed in v3.0.0.
- `render`: `RoadieRenderProp`. Swaps the underlying element with full control over its shape, such as `render={<article />}`.
- `className`: `string`.
- `size`: `"sm" | "md" | "lg"`. Defaults to `'md'`. Body size and heading scale. `sm` sets headings one step below the UI heading styles, for dense content such as an FAQ; `md` uses the UI heading styles; `lg` uses the prose heading styles, for editorial pages.

Docs updated 19 Oct 2026.
