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/typographyUsage
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
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 }