Skip to content

Typeset

Three controls — size, leading and flow — for every place the library renders markdown.

Rendered markdown is the one thing a component library cannot style with components. What arrives is whatever the renderer emitted: a heading, three paragraphs, a list, a table, sometimes a code block, none of them carrying a class you chose. Typeset styles that markup from the outside.

import { Spoiler } from "@/registry/bases/newt/ui/spoiler"
import { Typeset } from "@/registry/bases/newt/ui/typeset"

/*
 * One of every element a chat markdown parser emits, because that is what the
 * component has to survive: Typeset never sees the source, only the markup a
 * renderer produced from it.
 */
export default function TypesetDemo() {
  return (
    <Typeset as="article" preset="docs" measure>
      <h1>Heading one</h1>
      <h2>Heading two</h2>
      <h3>Heading three</h3>
      <p>
        A message body is <strong>bold</strong>, <em>italic</em>,{" "}
        <u>underlined</u>, <s>struck through</s>, and carries{" "}
        <code>inline code</code>. Links are{" "}
        <a href="#typeset-demo">written with a label</a> or pasted bare.
      </p>
      <p>
        The marks nest, so{" "}
        <strong>
          <em>bold italics</em>
        </strong>
        ,{" "}
        <u>
          <strong>underlined bold</strong>
        </u>{" "}
        and{" "}
        <u>
          <strong>
            <em>all three at once</em>
          </strong>
        </u>{" "}
        each arrive as elements inside elements.
      </p>
      <p>
        <code>||spoiler||</code> is not text styling — it is interface, so it
        stays a component rather than a class: <Spoiler>hidden text</Spoiler>{" "}
        until it is clicked.
      </p>
      <blockquote>
        <p>One chevron quotes a single line.</p>
        <p>
          Three of them quote everything after, to the end of the message — so a
          quote is a block of paragraphs, not one long line.
        </p>
      </blockquote>
      <ul>
        <li>A dash, an asterisk or a plus opens a bullet.</li>
        <li>
          A number and a dot open an ordered list:
          <ol>
            <li>which nests</li>
            <li>and keeps its own counter</li>
          </ol>
        </li>
      </ul>
      <pre>
        <code>{`const thread = await message.startThread({
  name: "release-notes",
})`}</code>
      </pre>
      <small>
        Subtext is the aside under a message: smaller, quieter, same rhythm —
        and it still holds an element, like{" "}
        <a href="#typeset-demo">a dismiss link</a>, at the same size.
      </small>
    </Typeset>
  )
}
<script setup lang="ts">
/*
 * One of every element a chat markdown parser emits, because that is what the
 * component has to survive: Typeset never sees the source, only the markup a
 * renderer produced from it.
 */
import { Spoiler } from "~~/registry/bases/newt/ui/spoiler"
import { Typeset } from "~~/registry/bases/newt/ui/typeset"
</script>

<template>
  <Typeset as="article" preset="docs" measure>
    <h1>Heading one</h1>
    <h2>Heading two</h2>
    <h3>Heading three</h3>
    <p>
      A message body is <strong>bold</strong>, <em>italic</em>,
      <u>underlined</u>, <s>struck through</s>, and carries
      <code>inline code</code>. Links are
      <a href="#typeset-demo">written with a label</a> or pasted bare.
    </p>
    <p>
      The marks nest, so <strong><em>bold italics</em></strong
      >, <u><strong>underlined bold</strong></u> and
      <u
        ><strong><em>all three at once</em></strong></u
      >
      each arrive as elements inside elements.
    </p>
    <p>
      <code>||spoiler||</code> is not text styling — it is interface, so it
      stays a component rather than a class: <Spoiler>hidden text</Spoiler>
      until it is clicked.
    </p>
    <blockquote>
      <p>One chevron quotes a single line.</p>
      <p>
        Three of them quote everything after, to the end of the message — so a
        quote is a block of paragraphs, not one long line.
      </p>
    </blockquote>
    <ul>
      <li>A dash, an asterisk or a plus opens a bullet.</li>
      <li>
        A number and a dot open an ordered list:
        <ol>
          <li>which nests</li>
          <li>and keeps its own counter</li>
        </ol>
      </li>
    </ul>
    <pre><code>const thread = await message.startThread({
  name: "release-notes",
})</code></pre>
    <small>
      Subtext is the aside under a message: smaller, quieter, same rhythm — and
      it still holds an element, like
      <a href="#typeset-demo">a dismiss link</a>, at the same size.
    </small>
  </Typeset>
</template>

Principles

Three controls, not thirty. Size, leading and flow decide almost everything about how a block of prose reads. --typeset-size is the base every other size is an em of, --typeset-leading is the line height, and --typeset-flow is the space between block children. Change one and the whole block stays proportional.

One rule owns the rhythm. Every block child carries the same trailing space and none carries a leading one, so a paragraph’s spacing never depends on what happens to sit above it. Headings are the exception, and they take their extra space from --typeset-flow too.

Everything else is a token. Colours, borders, radii and the code surface resolve to --newt-*, which is why a typeset block follows the palette, the light surfaces and a preset without knowing they exist.

Nothing inside is styled by name. No .typeset-heading, no .typeset-list. The selectors match elements, wrapped in :where() so their specificity is zero — any class you add wins without !important.

Installation

pnpm dlx newtui@latest add typeset
npx newtui@latest add typeset
yarn dlx newtui@latest add typeset
bunx newtui@latest add typeset

Install the dependencies

pnpm dlx --yes class-variance-authority
npx --yes class-variance-authority
yarn dlx --yes class-variance-authority
bunx --yes class-variance-authority

Add the components it builds on

Copy the source into your project

import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

/*
 * Typeset is a wrapper and two class names — the rules live in the stylesheet
 * `add typeset` appended, keyed on `.typeset`. Nothing inside needs a class of
 * its own, which is the point: what goes in is whatever a markdown renderer
 * emitted, and it comes out with the library's rhythm.
 */
const typesetVariants = cva("typeset", {
  variants: {
    preset: {
      docs: "typeset-docs",
      chat: "typeset-chat",
      article: "typeset-article",
    },
    measure: {
      true: "typeset-measure",
      false: "",
    },
  },
  defaultVariants: {
    preset: "docs",
    measure: false,
  },
})

export interface TypesetProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof typesetVariants> {
  /** Render as `<article>` or any other element that suits the content. */
  readonly as?: "div" | "article" | "section"
}

const Typeset = React.forwardRef<HTMLDivElement, TypesetProps>(
  ({ className, preset, measure, as: Component = "div", ...props }, ref) => (
    <Component
      ref={ref}
      className={cn(typesetVariants({ preset, measure }), className)}
      {...props}
    />
  )
)
Typeset.displayName = "Typeset"

export { Typeset, typesetVariants }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { typesetVariants, type TypesetVariants } from "."

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    preset?: TypesetVariants["preset"]
    measure?: boolean
    /** Render as `<article>` or any other element that suits the content. */
    as?: "div" | "article" | "section"
  }>(),
  { as: "div" }
)
</script>

<template>
  <component
    :is="props.as"
    :class="
      cn(
        typesetVariants({ preset: props.preset, measure: props.measure }),
        props.class
      )
    "
  >
    <slot />
  </component>
</template>
import { cva, type VariantProps } from "class-variance-authority"

export { default as Typeset } from "./Typeset.vue"

/*
 * Typeset is a wrapper and two class names — the rules live in the stylesheet
 * `add typeset` appended, keyed on `.typeset`. Nothing inside needs a class of
 * its own, which is the point: what goes in is whatever a markdown renderer
 * emitted, and it comes out with the library's rhythm.
 */
export const typesetVariants = cva("typeset", {
  variants: {
    preset: {
      docs: "typeset-docs",
      chat: "typeset-chat",
      article: "typeset-article",
    },
    measure: {
      true: "typeset-measure",
      false: "",
    },
  },
  defaultVariants: {
    preset: "docs",
    measure: false,
  },
})

export type TypesetVariants = VariantProps<typeof typesetVariants>

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

The stylesheet lands next to your global CSS and is imported after Tailwind, so the :where() rules sit at the end of the cascade where they can be overridden by anything.

The three controls

Variable Default What it decides
--typeset-size 1em The base font size. Headings and code are em of it.
--typeset-leading 1.75 Line height for body text. Headings stay at 1.25.
--typeset-flow 1.25em The gap between block children, and the basis for the heading gap.

Three more name the fonts, and each resolves to a token by default: --typeset-font-body, --typeset-font-heading, --typeset-font-mono.

The builder moves all six live and prints what you land on.

Presets

Three combinations cover the surfaces this library renders into:

Class Size Leading Flow For
typeset-docs 1em 1.75 1.25em Long-form reading — guides, changelogs
typeset-chat 0.9375em 1.375 0.5em Conversation, where the turn is the unit
typeset-article 1.0625em 1.8 1.5em Editorial text somebody sat down to read
<article class="typeset typeset-chat">
  <!-- whatever your markdown renderer emitted -->
</article>

Building your own

A project with its own rhythm names it once and puts that name beside typeset:

.typeset-brief {
  --typeset-size: 0.9375em;
  --typeset-leading: 1.5;
  --typeset-flow: 0.75em;
  --typeset-font-body: var(--newt-font-sans);
}
<Typeset preset="chat" className="typeset-brief">
  {rendered}
</Typeset>

The preset still supplies the base; the class after it overrides the parts you named. That is the whole extension mechanism.

Measure

Line length belongs to the column, not to the type, so it is a separate class: typeset-measure caps the block at 68ch. It is on by default in the React and Vue wrappers through the measure prop, and off in the bare class list until you add it.

A block inside a fixed-width column — a sidebar, an embed — usually wants it off, because the column already decided.

Themes and dark mode

Nothing to do. Every colour is a token, so the light surfaces (Dark mode) and a brand hue from newt/create both reach a typeset block without a second rule.

RTL

Also nothing to do: the stylesheet is written in logical properties throughout — padding-inline-start for lists and quotes, border-inline-start for the quote rule, text-align: start in tables. A dir="rtl" anywhere above the wrapper is the whole feature. See RTL.

The responsive table

A table is the one element that cannot reflow. Below 40rem a direct child table scrolls sideways inside its own box rather than widening the page, which keeps the surrounding prose readable at the cost of a horizontal scroll on the one element that needs it.

Overrides

Every selector is wrapped in :where(), so its specificity is zero. A utility class, a component class or a plain element rule all win:

<article class="typeset typeset-docs">
  <p class="text-newt-text-muted">This paragraph is muted, no !important.</p>
</article>

Opting out

An island of interface inside a body of prose — a component, an embed, a table of controls — should not inherit the rhythm:

<article class="typeset typeset-docs">
  <p>Prose.</p>

  <div class="not-typeset">
    <!-- components in here keep their own spacing and type -->
  </div>
</article>

not-typeset reverts margin, font, size, line height and colour for the element and everything under it.

Streaming

Text that arrives a token at a time reflows constantly, and the rhythm is what keeps that from looking like a fault. Because the spacing is a single trailing margin per block, a paragraph that is still being written has the same geometry as one that is finished — nothing shifts when the last word lands. Applying typeset-chat to a streaming assistant reply is the usual case.

Prior art

The three-variable model is shadcn’s Typeset, and it is the right abstraction — this implementation keeps it and resolves everything else to --newt-*. Tailwind Typography’s prose solves the same problem with a much larger surface, and Every Layout’s Stack is where the “one trailing margin per child” rule comes from.