Skip to content
ssntl/ui

Typography

Heading, Text and Eyebrow on the system's type roles, each with its own line height and tracking.

Field notes

Less noise, more room to think

Big type sits tight so a two-line title reads as one shape.

Reading text gets the opposite: more space between lines, so the eye finds the start of the next one without effort, and tracking close to zero, because the font was drawn for this size.

Headline 48 · 1.04 · −0.035em — Body 17 · 1.6 · −0.005em — Eyebrow 12 · 1 · +0.08em

Installation

$ pnpm dlx shadcn@latest add @ssntl/typography

Usage

tsx
import { Eyebrow, Heading, Text } from "@/components/ui/typography"

<Eyebrow>Field notes</Eyebrow>
<Heading as="h1" size="headline">Less noise, more room to think</Heading>
<Text size="lead" tone="muted">Big type sits tight.</Text>
<Text>Reading text gets more space between lines.</Text>
  • Line height falls as size grows: display 0.98, headline 1.04, heading 1.25, body 1.6. Each role brings its own.
  • Tracking does the same: large type tightens (down to −0.045em), body stays near zero, the uppercase eyebrow opens up (+0.08em).
  • Never add leading-* or tracking-* on top of a role; if it looks wrong, the role is wrong.
  • as sets the document level, size sets the look: an h2 can be a title or a heading.

Code

components/ui/typography.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

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

// Essential Design type roles. Each role already carries its line height and
// tracking: big type is set tight (display 0.98, −0.045em), reading text loose
// (body 1.6), and the uppercase eyebrow opens up (+0.08em). Pick the role; never
// add leading-* or tracking-* on top.
const headingVariants = cva("text-balance", {
  variants: {
    size: {
      display: "text-display",
      headline: "text-headline",
      title: "text-title",
      heading: "text-heading",
    },
  },
  defaultVariants: { size: "heading" },
})

const textVariants = cva("text-pretty", {
  variants: {
    size: {
      lead: "text-lead",
      body: "text-body",
      caption: "text-caption",
    },
    tone: {
      default: "text-foreground",
      muted: "text-muted-foreground",
    },
  },
  defaultVariants: { size: "body", tone: "default" },
})

function Heading({
  as: Comp = "h2",
  size,
  className,
  ...props
}: React.ComponentProps<"h2"> &
  VariantProps<typeof headingVariants> & {
    /** The document level, independent of the size. */
    as?: "h1" | "h2" | "h3" | "h4"
  }) {
  return (
    <Comp
      data-slot="heading"
      className={cn(headingVariants({ size, className }))}
      {...props}
    />
  )
}

function Text({
  size,
  tone,
  className,
  ...props
}: React.ComponentProps<"p"> & VariantProps<typeof textVariants>) {
  return (
    <p
      data-slot="text"
      className={cn(textVariants({ size, tone, className }))}
      {...props}
    />
  )
}

// A small uppercase label above a heading. Uppercase needs the extra tracking
// the role brings, or the letters crowd.
function Eyebrow({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="eyebrow"
      className={cn("text-eyebrow text-muted-foreground uppercase", className)}
      {...props}
    />
  )
}

export { Eyebrow, Heading, Text, headingVariants, textVariants }