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 typesetnpx newtui@latest add typesetyarn dlx newtui@latest add typesetbunx newtui@latest add typesetInstall the dependencies
pnpm dlx --yes class-variance-authoritynpx --yes class-variance-authorityyarn dlx --yes class-variance-authoritybunx --yes class-variance-authorityAdd 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.