Saltar al contenido
ssntl/ui

Typography

Heading, Text y Eyebrow sobre los roles tipográficos del sistema, cada uno con su interlineado y 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

Instalación

$ pnpm dlx shadcn@latest add @ssntl/typography

Uso

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

<Eyebrow>Notas de campo</Eyebrow>
<Heading as="h1" size="headline">Menos ruido, más espacio para pensar</Heading>
<Text size="lead" tone="muted">El texto grande va ajustado.</Text>
<Text>El texto de lectura lleva más espacio entre líneas.</Text>
  • El interlineado baja cuando sube el tamaño: display 0.98, headline 1.04, heading 1.25, body 1.6. Cada rol trae el suyo.
  • El tracking igual: el texto grande se cierra (hasta −0.045em), el cuerpo queda cerca de cero y el eyebrow en mayúsculas se abre (+0.08em).
  • Nunca agregues leading-* ni tracking-* encima de un rol; si se ve mal, el rol es el equivocado.
  • as define el nivel del documento y size el aspecto: un h2 puede ser title o heading.

Código

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 }