Skip to content

COMPONENT

Prose

Long-form reading, opted into. Everything inside .prose is set for somebody reading sentences rather than scanning an interface — and .prose-full is the version for an article that is more structure than sentences.

An article

Why undo is the hardest feature in any editor

Every drawing tool promises undo, most ship a version that works until the day it does not, and by then the shape of the mistake is baked into the data model.

The first version is always a stack of snapshots. It is easy to write, easy to explain, and it works beautifully for the first three weeks of a project — which is exactly long enough for it to become load-bearing.

One action is whatever the user thinks it was

Dragging a selection of nine shapes is one action. Typing a word is one action, but typing a paragraph is several, and nobody has ever been able to say precisely where the seams fall.

  • Stable identity. Nothing in the history points at an object; it points at a name.
  • Coalescing rules. Written down in one file, not scattered across the edit handlers.
  • A visible history. If the user can see the list, they will tell you when it is wrong.
The bug is not that undo failed. The bug is that it succeeded into a document that never existed. — a note left in the source, four years ago
interface Command {
  target: ObjectId          // a name, never a pointer
  apply(doc: Doc): void
  revert(doc: Doc): void
}
PLACEHOLDER The history panel, mid-undo
The list the user can see is the list that gets bug reports. Nine shapes dragged together, one entry.

The fix is unglamorous, and you can read more in the full piece.

HTML
<article class="prose">
  <h1>Why undo is the hardest feature in any editor</h1>
  <p class="prose-lead">Every drawing tool promises undo, most ship a version that works until the day it does not, and by then the shape of the mistake is baked into the data model.</p>
  <p>The first version is always a stack of snapshots. It is easy to write, easy to explain, and it works beautifully for the first three weeks of a project — which is exactly long enough for it to become load-bearing.</p>
  <h3>One action is whatever the user thinks it was</h3>
  <p>Dragging a selection of nine shapes is one action. Typing a word is one action, but typing a paragraph is several, and nobody has ever been able to say precisely where the seams fall.</p>
  <ul>
    <li><strong>Stable identity.</strong> Nothing in the history points at an object; it points at a name.</li>
    <li><strong>Coalescing rules.</strong> Written down in one file, not scattered across the edit handlers.</li>
    <li><strong>A visible history.</strong> If the user can see the list, they will tell you when it is wrong.</li>
  </ul>
  <blockquote>
    The bug is not that undo failed. The bug is that it succeeded into a document that never existed.
    <cite>— a note left in the source, four years ago</cite>
  </blockquote>
  <pre class="prose-wide"><code>interface Command {
  target: ObjectId          // a name, never a pointer
  apply(doc: Doc): void
  revert(doc: Doc): void
}</code></pre>
  <figure>
    <div class="placeholder" style="--ratio: 16/9">
      <span class="placeholder-kicker">PLACEHOLDER</span>
      <span class="placeholder-note">The history panel, mid-undo</span>
    </div>
    <figcaption>The list the user can see is the list that gets bug reports. Nine shapes dragged together, one entry.</figcaption>
  </figure>
  <hr>
  <aside class="prose-aside">Redo is the same machine run backwards, which is why teams that treat it as a separate feature end up with two histories that disagree.</aside>
  <p>The fix is unglamorous, and you can read more <a href="#">in the full piece</a>.</p>
</article>

The same markup, with and without .prose-full

The same markup twice. Above, the cap is on the article and the grid is stuck in the reading column. Below, .prose-full moves the cap to the children — the paragraph is unchanged and the grid takes the whole block. The measure is shortened to 38ch in this specimen so both fit the book's own column; nothing else differs.

.prose — the block is the measure

The cap sits on the article, so everything inside it is trapped in a reading column, including the things that are not sentences.

Autosave

Every thirty seconds, and again when the window loses focus.

History

One entry per action, where an action is whatever you thought it was.

Recovery

The file on disk is not the file you are editing until you say so.

.prose-full — the children are

The cap moves to the direct children, so this paragraph is the same width it was and the grid below takes the whole block.

Autosave

Every thirty seconds, and again when the window loses focus.

History

One entry per action, where an action is whatever you thought it was.

Recovery

The file on disk is not the file you are editing until you say so.

HTML
<!-- The measure is shortened here so both blocks fit the book's column and the
     difference is visible. Your own pages keep the 70ch default. -->
<div class="flow" style="--ds-measure:38ch">
  <article class="prose">
    <h3>.prose &mdash; the block is the measure</h3>
    <p>The cap sits on the article, so everything inside it is trapped in a reading column, including the things that are not sentences.</p>
    <div class="hairline-grid prose-wide" style="--cell:150px">
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">Autosave</span>
        <p class="feature-cell-body">Every thirty seconds, and again when the window loses focus.</p>
      </div>
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">History</span>
        <p class="feature-cell-body">One entry per action, where an action is whatever you thought it was.</p>
      </div>
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">Recovery</span>
        <p class="feature-cell-body">The file on disk is not the file you are editing until you say so.</p>
      </div>
    </div>
  </article>

  <article class="prose prose-full">
    <h3>.prose-full &mdash; the children are</h3>
    <p>The cap moves to the direct children, so this paragraph is the same width it was and the grid below takes the whole block.</p>
    <div class="hairline-grid prose-wide" style="--cell:150px">
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">Autosave</span>
        <p class="feature-cell-body">Every thirty seconds, and again when the window loses focus.</p>
      </div>
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">History</span>
        <p class="feature-cell-body">One entry per action, where an action is whatever you thought it was.</p>
      </div>
      <div class="hairline-cell feature-cell">
        <span class="feature-cell-title">Recovery</span>
        <p class="feature-cell-body">The file on disk is not the file you are editing until you say so.</p>
      </div>
    </div>
  </article>
</div>

List markers, and the knobs that change them

Four reading columns, differing only in the custom properties set on each one. Top to bottom: the default disc and decimal; a string marker with Roman numerals beside it; a base64 SVG; and the outside position with an indent made for it.

The default. A disc, inside, flush with everything else in the column.

  • Stable identity, so nothing in the history points at an object.
  • Coalescing rules, written down in one file rather than scattered across the edit handlers.
  1. Snapshot the whole document.
  2. Discover it does not scale.
  3. Write the command objects you should have started with.

A string, and a different counter. Both paint in the text colour, so both follow the theme.

  • Stable identity, so nothing in the history points at an object.
  • Coalescing rules, written down in one file.
  1. Snapshot the whole document.
  2. Discover it does not scale.

A base64 SVG. It carries its own colour, which is the one thing to watch.

  • Stable identity, so nothing in the history points at an object.
  • Coalescing rules, written down in one file.

Outside, with an indent to hang in. The two settings travel together.

  • Stable identity, so nothing in the history points at an object, and a wrapped line clears the marker instead of running under it.
  • Coalescing rules, written down in one file.
HTML
<div class="flow flow-loose">
  <div class="prose">
    <p class="prose-lead">The default. A disc, inside, flush with everything else in the column.</p>
    <ul>
      <li>Stable identity, so nothing in the history points at an object.</li>
      <li>Coalescing rules, written down in one file rather than scattered across the edit handlers.</li>
    </ul>
    <ol>
      <li>Snapshot the whole document.</li>
      <li>Discover it does not scale.</li>
      <li>Write the command objects you should have started with.</li>
    </ol>
  </div>

  <div class="prose" style="--prose-marker: '— '; --prose-marker-ordered: upper-roman">
    <p class="prose-lead">A string, and a different counter. Both paint in the text colour, so both follow the theme.</p>
    <ul>
      <li>Stable identity, so nothing in the history points at an object.</li>
      <li>Coalescing rules, written down in one file.</li>
    </ul>
    <ol>
      <li>Snapshot the whole document.</li>
      <li>Discover it does not scale.</li>
    </ol>
  </div>

  <div class="prose" style="--prose-marker-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHZpZXdCb3g9IjAgMCAxMCAxMCI+PHBhdGggZD0iTTIgMSBMNyA1IEwyIDkiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2M5OGEyZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIi8+PC9zdmc+')">
    <p class="prose-lead">A base64 SVG. It carries its own colour, which is the one thing to watch.</p>
    <ul>
      <li>Stable identity, so nothing in the history points at an object.</li>
      <li>Coalescing rules, written down in one file.</li>
    </ul>
  </div>

  <div class="prose" style="--prose-marker-position: outside; --prose-indent: 24px">
    <p class="prose-lead">Outside, with an indent to hang in. The two settings travel together.</p>
    <ul>
      <li>Stable identity, so nothing in the history points at an object, and a wrapped line clears the marker instead of running under it.</li>
      <li>Coalescing rules, written down in one file.</li>
    </ul>
  </div>
</div>

Reference

Read from components/prose.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.prose.prose-full.prose-wide.prose-lead.prose-aside
SETTINGSSet the first on one element; a theme sets the second for all of them.--prose-indent — falls back to --ds-prose-indent--prose-marker-position — falls back to --ds-prose-marker-position--prose-marker — falls back to --ds-prose-marker--prose-marker-ordered — falls back to --ds-prose-marker-ordered
READS--ds-accent--ds-focus-width--ds-font-body--ds-font-display--ds-gap--ds-gap-loose--ds-ground--ds-hairline--ds-ink--ds-ink-faint--ds-ink-read--ds-ink-soft--ds-measure--ds-pad-card--ds-prose-gap--ds-rule--ds-rule-strong--ds-text-body--ds-text-caption--ds-text-display--ds-text-h2--ds-text-h3--ds-text-lead--ds-text-small--ds-track-snug--ds-track-tight--ds-underline-offset--ds-weight-display--ds-weight-strong

Notes

WHY THIS IS A COMPONENT AND NOT A BASE STYLE

Styling h2 and p globally means every card, label and toolbar in the system inherits reading typography and has to opt back out. Most pages are interfaces, not documents. Opting in is one class; opting out is a fight.

The measure is 70ch, not a pixel width, so it survives a change of font size — a 640px column is 70 characters at one size and 45 at another.

🛑 IT STYLES THE TAGS YOU TYPED, AND NOTHING A COMPONENT BROUGHT

Fair question: what happens when a component lands inside an article? Prose styles bare p, a and ul — and a component is built out of those same tags. So a button in a paragraph used to pick up the article underline, and a list module used to pick up the article indent.

Every element rule here now matches unclassed elements only. A tag with a class on it came from a component, and a component owns its own paint. You write Markdown; Markdown emits bare tags; nothing changes for you.

Specificity was tried first and it cannot do this job. The link rule is deliberately weak so a button keeps its own colour — and it does keep its colour. It does not keep its underline, because the button never mentions one and there is no contest to win. Specificity settles who wins a property both rules set. The question was whether the rule should apply at all.

Measured across this book and both sites installing it, before the change:

44 LINKSunderlined that no author typed
1 MODAL TITLEwearing the article h2 — 36px, not 23px
32 FIGURESa lightbox slide laid out by the article
AFTER236 unclassed elements, none moved

One tag is exempt, and it is the interesting one: <pre>. The syntax highlighter puts its own classes on a fenced block, so filtering it would leave every code block unstyled. Its rule lives in code-block instead.

Adding a component to the library adds nothing to that list. What is filtered is HTML tags, and HTML closes that list for us.

THE THEMATIC BREAK WAS FOUR SHADES TOO LOUD, AND IT TOOK A MARKDOWN FILE TO FIND IT

Every other tag on this page was styled and hr was not. Nobody noticed, because nobody types one by hand. A Markdown pipeline does: three hyphens on a line, and you have a divider you never wrote.

Left to the browser under the reset, that divider is a 1px line in currentColor — which inside an article is --ds-ink-read. A site rendering its licence from Markdown drew it at #2a4655 with #e4dfd4 on every other rule beside it. It looked like a rendering fault, and in this system it was one: there is one hairline weight, and that was not it.

It reads --ds-rule now, drawn with the same border-block-start every other divider in the library uses — so a theme that restates that role moves this too. There is no margin on it: .prose is a flex column with a gap, and the space around a break is the gap, like everything else.

.prose-wide DOES NOTHING ON ITS OWN, AND .prose-full IS WHAT MAKES IT WORK

For a long time this page told you a wide child escaped the reading column, and it did not. The cap is max-inline-size on .prose itself — a child setting max-inline-size: none is still 100% of a box that is already capped.

.prose-full moves the cap from the block to its children. The article fills whatever it is given, every direct child still reads at --ds-measure, and .prose-wide on one of them finally does what its name says. That is the second specimen above.

Measured on this page, in an 1100px stage:

.PROSE770px — and its .prose-wide child, 770px
.PROSE-FULL1022px — paragraphs 770px, .prose-wide 1022px
UNDER 770PXidentical — nothing to escape from

It is a modifier rather than a change to .prose on purpose. Moving the cap for everybody changes the width of every prose block in the library and in anything installing it, which is a decision with a screenshot pass attached rather than a tidy-up.

🛑 DO NOT REACH FOR IT IF YOU CONTROL THE MARKUP

When the prose must close, let it close, and let the wide thing stand outside as a sibling. A .prose is a reading column. A grid, a gallery or a run of bands is structure rather than reading, and putting one inside a reading column and then widening it back out is arguing with a component instead of not using it.

That is a trap and not a preference, because a module dropped into a .prose cannot defend itself. feature-band is an <ol> and declares padding: 0. The rule on .prose ol wins anyway — not on specificity but on layer, since ds-components comes after ds-modules and a later layer wins regardless. The bands shipped indented 24px from every other left edge on their page.

The filter above prevents that particular one now — a feature-band is a classed <ol>, so no prose rule reaches it. The advice stands anyway, because paint was only half of it. A run of bands inside a seventy-character column is the wrong shape whatever colour it comes out.

And closing the prose no longer costs you anything. Put measure on the sibling and it keeps the reading width. That primitive exists for this exact sentence — the rule used to cost 350 pixels, which is why nobody followed it.

So what is it for? The case where closing is not available: a Markdown body. render(entry) hands back one sealed component, so the whole article arrives inside a single block and there is no seam to close at. That is a real constraint rather than a preference, and it is the only one this modifier exists to answer.

One edge, and it is a real one: it reaches direct children only. Wrap something in a <div> and the wrapper takes the measure, with everything inside it stuck in that box. Put .prose-wide on the wrapper, or do not wrap.

🛑 THERE WAS NO LIST MARKER AT ALL UNTIL 25 AUGUST 2026

The reset sets list-style: none on every <ul> and <ol>. This file set a left padding and never put a marker back. Measured on this very page: list-style-type: none with 24px of padding — so every list in every article was an unmarked block, indented for no visible reason.

It survived because it looked deliberate. An unstyled list and a styled-flush list are the same picture until you go looking for the bullet that is not there.

The default now is a disc, drawn inside the content box, with no indent at all — so a wrapped line runs back to the same left edge as every paragraph around it. That is a taste, which is why all five parts of it are yours to change:

--prose-markerthe ul glyph. A keyword, or a string
--prose-marker-imagethe ul marker as a picture. A data URI
--prose-marker-orderedthe ol counter: lower-roman, upper-alpha
--prose-marker-positioninside, the default, or outside
--prose-indentthe list’s own padding. Zero by default

Custom properties inherit, so set them wherever suits: on :root for a whole site, on one article, or on a single list.

.prose {
  --prose-marker-image: url("data:image/svg+xml;base64,PHN2Zy…");
}

Two things will bite you, and both are about the picture rather than the CSS.

Give the SVG a width and a height before you encode it. A marker image draws at its own intrinsic size and no CSS property resizes one — there is no background-size to reach for, because it is not a background. An SVG carrying only a viewBox has no intrinsic size, so the browser falls back to 300×150 and you get a marker taller than the paragraph next to it. Two attributes on the root element are the whole fix.

A picture cannot follow your theme. The colour is baked into the data URI and no variable reaches inside it. Measured on the specimen above: the default disc moved from rgb(198, 210, 218) to rgb(42, 70, 85) between the two themes, and the chevron stayed exactly the amber it was drawn as. Pick a colour that survives both grounds, or reach for a string marker instead — those paint in the text colour and follow the palette for free.

A FIGURE GETS ITS CAPTION FOR FREE

Write <figure> and <figcaption> and there is no class to remember — which is the same call this component makes about every other tag an author types. The figure stacks its parts; the caption drops to --ds-text-caption and --ds-ink-faint.

It had no rule at all until 22 August 2026, and the failure was the quiet kind: a caption inherited the column's own size and ink, so it measured 17px against 17px in the identical colour and read as the next sentence rather than as a line about the picture. The values are placeholder-caption’s, because that is already this system’s spelling of “a line under a picture” — and --ds-text-caption names figcaptions first in its own declaration. The most literal figcaption in the library was the one not reading it.

A MARKDOWN FILE MAY OPEN WITH ITS OWN TITLE

h1 is styled here as well as h2 to h4, because the most natural thing an author writes at the top of an article is # followed by the title. It was missing, and the failure was a quiet one: an unstyled heading inherits the body family at the base size, so a title rendered smaller than the lead beneath it and read as a deliberate eyebrow rather than a mistake.

It carries no top margin, unlike the other steps: a title opens the flow, and a margin on the first child is the collapse the rhythm exists to avoid.