Skip to content

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 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.

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

PropType
preset?"docs" | "chat" | "article"

Which of the three rhythms to start from. Each sets size, leading and flow.

Type"docs" | "chat" | "article"
Default"docs"
measure?boolean

Caps the line length at 68ch. Line length belongs to the column, not to the type.

Typeboolean
Defaultfalse
as?"div" | "article" | "section"

The element to render. Long-form content usually wants article.

Type"div" | "article" | "section"
Default"div"
children / default slot?React.ReactNode / Vue slot

The rendered markup. It needs no classes of its own.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

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.