← All components

Prose

A classless wrapper that puts imported Markdown on the package's type ladder.

Most of the churn was seasonal

Cancellations rose every August for three years and fell back by October. The part that wasn’t seasonal came from one place: the annual plan’s renewal email, which arrived after the card had been charged. See the method, or run churn --by-month.

What we changed

  • The renewal notice goes out fourteen days before the charge.
  • Pausing is one press, not a support ticket.

People weren’t leaving the product. They were leaving a surprise.

MonthBeforeAfter
August6.1%3.4%
September4.8%2.9%

The idea

Imported Markdown arrives as bare elements with no class to hang a style on, so it is styled by element under one scoped class and mapped onto the same ladder as everything else — 18, 16, 14, 13, two weights, leading tight on headings and open at 1.75 on body, tracking only above 14. Emphasis moves to axes the system already has: `em` drops to the muted tier instead of slanting, `strong` is 500. Code and tables scroll in their own box, never the page.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Prose — usage9 lines
import { Prose } from "mihirmodi-ui";

// Rendered Markdown, styled by element under one scoped class.
<Prose as="article" className="max-w-[640px]">
  <MDXContent />
</Prose>

// Or an HTML string — sanitise anything you did not write.
<Prose html={post.html} className="max-w-[640px]" />

Props

PropNotes
childrenReactNodeRendered Markdown — MDX, a renderer, plain tags.
htmlstringOr an HTML string. Inserted as is: sanitise anything you did not write.
asElementType`article` for a whole document.
classNamestringSet the measure here — `max-w-[640px]`.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/layout/Prose.tsx39 lines
import type { ElementType, ReactNode } from "react";

export type ProseProps = {
  /** Rendered Markdown as elements — from MDX, a renderer, or hand-written tags. */
  children?: ReactNode;
  /**
   * Or an HTML string from a CMS or a Markdown-to-HTML step. It is inserted
   * as-is, so it must come from you or be sanitised first.
   */
  html?: string;
  /** The wrapping element. `article` for a whole document. */
  as?: ElementType;
  /** Set the measure here — `max-w-[640px]` keeps a line to about 75 characters. */
  className?: string;
};

/**
 * A classless wrapper for prose you did not write the markup for.
 *
 * Imported Markdown arrives as bare `h2`, `p`, `ul` and `pre`, with no class
 * to hang a style on, so the styling is by element under one scoped class
 * (`.ui-prose` in the package's stylesheet) and it is mapped onto the same
 * ladder as everything else: 18 for a title, 16 for a heading, 14 for body
 * and sub-headings, 13 for a fourth level and for tables. Two weights. Leading
 * runs inverse to size — headings tight, body open at 1.75. Tracking only
 * pulls in the two sizes above 14. An imported document is a guest in the
 * type system, not a second one.
 *
 * Emphasis moves to axes the system already has: `em` drops to the muted
 * tier instead of slanting, and `strong` is 500, not a 700 blot. Lists get a
 * small dot of our own, ordered lists tabular numbers. Code blocks and
 * tables scroll inside their own box, so the page itself never scrolls
 * sideways. The first child loses its top margin, so a document that opens
 * on a heading does not push a gap above itself.
 */
export function Prose({ children, html, as: Tag = "div", className = "" }: ProseProps) {
  if (html != null) return <Tag className={`ui-prose ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
  return <Tag className={`ui-prose ${className}`}>{children}</Tag>;
}