Typeset
Styles rendered markdown with three variables — size, leading and flow — over the --newt-* tokens.
Rendered markdown is the one thing a component library cannot style with
components: what arrives is whatever the renderer emitted. Typeset is a wrapper
and two class names — the rules are the stylesheet add typeset appends, and
nothing inside needs a class of its own.
The builder tunes the three variables live; the guide explains what each one does.
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>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.
Usage
import { Typeset } from "@/components/ui/typeset"
export function Example() {
return <Typeset />
}<script setup lang="ts">
import { Typeset } from "@/components/ui/typeset"
</script>
<template>
<Typeset />
</template>What it styles
Typeset never sees markdown — it sees whatever a renderer emitted from it. The left column is the syntax a chat client accepts, the middle is the element that reaches the wrapper, and everything in it is styled by the stylesheet without a class of its own.
| Markdown | Element | Notes |
|---|---|---|
# H1 ## H2 ### H3 |
<h1> <h2> <h3> |
h4–h6 are styled too, for renderers that go deeper. |
**bold** |
<strong> |
Weight 600 and the primary text colour. |
*italic* |
<em> |
|
__underline__ |
<u> |
Same underline offset as a link, so the two agree. |
~~strikethrough~~ |
<s> |
Falls back to the muted colour. |
***bold italics*** |
<strong><em> |
The marks nest, and so do the elements they produce. |
__**underline bold**__ |
<u><strong> |
Any combination of the four, in any order. |
`inline code` |
<code> |
Monospace on the surface colour. |
```lang fenced block |
<pre><code> |
Scrolls sideways rather than widening the column. |
[label](url) |
<a> |
Link colour, underlined. |
> quote / >>> quote |
<blockquote> |
Rule on the inline start edge. Holds several paragraphs. |
- item / 1. item |
<ul> / <ol> |
Nested lists keep their own counter and a tighter gap. |
-# subtext |
<small> |
Smaller and quieter, on the same rhythm. |
||spoiler|| |
<Spoiler> |
Interface, not styling — see below. |
API reference
Typeset
preset?"docs" | "chat" | "article"
Which of the three rhythms to start from. Each sets size, leading and flow.
"docs" | "chat" | "article""docs"measure?boolean
Caps the line length at 68ch. Line length belongs to the column, not to the type.
booleanfalseas?"div" | "article" | "section"
The element to render. Long-form content usually wants article.
"div" | "article" | "section""div"children / default slot?React.ReactNode / Vue slot
The rendered markup. It needs no classes of its own.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringAccessibility
The wrapper adds no semantics of its own, which is the point: the headings,
lists and tables a renderer emitted keep theirs. Two things the styles do carry
are accessibility decisions rather than taste — the measure exists so a line
does not outrun the eye returning to the next one, and the sizes are em so a
reader who has enlarged their default text enlarges all of it.
Mark an island of interface inside the prose with not-typeset and it keeps
its own spacing and type.
Tokens
Typeset reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.