Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 6

Typography: Heading, Text, and Prose

This chapter adds three components that enforce typographic consistency through the same token system that drives the rest of the library.

level 1 Typography level 2 Heading, Text, and Prose prose Body copy sits on the same grid, so every size and level keeps one vertical rhythm.
One type scale on one baseline grid, shared by Heading, Text, and Prose.

Layout primitives handle where things go. Typography components handle how text looks and how it flows. Most component libraries skip this layer entirely, leaving every heading size, text variant, and content spacing decision to the consumer. The result is inconsistency: five developers make five different choices about how large an h2 should be.

Typography tokens

Add a typography namespace to tokens/components.json, alongside the component and layout namespaces from chapter 3. Typography is a component-tier concern: heading.2.size is a statement about a heading, not about the system’s type scale, which already lives in tier 1 as font.size.xl.

{
  "typography": {
    "heading": {
      "1": {
        "size": { "$value": "{font.size.2xl}" },
        "weight": { "$value": "{font.weight.bold}" },
        "line-height": { "$value": "{font.lineHeight.tight}" },
        "tracking": { "$value": "-0.025em" },
        "color": { "$value": "{color.text.default}" }
      },
      "2": {
        "size": { "$value": "{font.size.xl}" },
        "weight": { "$value": "{font.weight.semibold}" },
        "line-height": { "$value": "{font.lineHeight.tight}" },
        "tracking": { "$value": "-0.025em" },
        "color": { "$value": "{color.text.default}" }
      },
      "3": {
        "size": { "$value": "{font.size.lg}" },
        "weight": { "$value": "{font.weight.semibold}" },
        "line-height": { "$value": "{font.lineHeight.tight}" },
        "tracking": { "$value": "0" },
        "color": { "$value": "{color.text.default}" }
      },
      "4": {
        "size": { "$value": "{font.size.base}" },
        "weight": { "$value": "{font.weight.semibold}" },
        "line-height": { "$value": "{font.lineHeight.tight}" },
        "tracking": { "$value": "0" },
        "color": { "$value": "{color.text.default}" }
      },
      "5": {
        "size": { "$value": "{font.size.sm}" },
        "weight": { "$value": "{font.weight.semibold}" },
        "line-height": { "$value": "{font.lineHeight.tight}" },
        "tracking": { "$value": "0" },
        "color": { "$value": "{color.text.default}" }
      },
      "6": {
        "size": { "$value": "{font.size.xs}" },
        "weight": { "$value": "{font.weight.semibold}" },
        "line-height": { "$value": "{font.lineHeight.normal}" },
        "tracking": { "$value": "0" },
        "color": { "$value": "{color.text.default}" }
      }
    },
    "body": {
      "size": { "$value": "{font.size.base}" },
      "weight": { "$value": "{font.weight.regular}" },
      "line-height": { "$value": "{font.lineHeight.normal}" },
      "color": { "$value": "{color.text.default}" }
    },
    "body-sm": {
      "size": { "$value": "{font.size.sm}" },
      "weight": { "$value": "{font.weight.regular}" },
      "line-height": { "$value": "{font.lineHeight.normal}" },
      "color": { "$value": "{color.text.default}" }
    },
    "caption": {
      "size": { "$value": "{font.size.sm}" },
      "weight": { "$value": "{font.weight.regular}" },
      "line-height": { "$value": "{font.lineHeight.normal}" },
      "color": { "$value": "{color.text.subtle}" }
    },
    "overline": {
      "size": { "$value": "{font.size.xs}" },
      "weight": { "$value": "{font.weight.semibold}" },
      "line-height": { "$value": "{font.lineHeight.normal}" },
      "tracking": { "$value": "0.1em" },
      "color": { "$value": "{color.text.subtle}" }
    },
    "code": {
      "font-size": { "$value": "0.875em" },
      "padding-y": { "$value": "0.125em" },
      "padding-x": { "$value": "0.25em" },
      "bg": { "$value": "{color.background.surface-raised}" },
      "radius": { "$value": "{radius.sm}" },
      "font-family": { "$value": "{font.family.mono}" }
    },
    "prose": {
      "space": { "$value": "1.5em" },
      "heading-before": { "$value": "2em" },
      "heading-after": { "$value": "0.75em" },
      "size-sm": { "$value": "{font.size.sm}" },
      "size-lg": { "$value": "{font.size.lg}" },
      "link-color": { "$value": "{color.brand.primary}" },
      "link-hover-color": { "$value": "{color.brand.primary-hover}" },
      "strong-weight": { "$value": "{font.weight.bold}" },
      "strong-color": { "$value": "{color.text.default}" },
      "blockquote-border": { "$value": "{color.border.default}" },
      "blockquote-color": { "$value": "{color.text.subtle}" },
      "code-bg": { "$value": "{color.background.surface-raised}" },
      "pre-bg": { "$value": "{color.background.surface-raised}" },
      "pre-border": { "$value": "{color.border.default}" },
      "pre-radius": { "$value": "{radius.md}" },
      "pre-font-size": { "$value": "{font.size.sm}" },
      "pre-line-height": { "$value": "{font.lineHeight.relaxed}" },
      "hr-color": { "$value": "{color.border.default}" },
      "table-border": { "$value": "{color.border.default}" },
      "table-header-weight": { "$value": "{font.weight.semibold}" },
      "figure-radius": { "$value": "{radius.md}" },
      "caption-color": { "$value": "{typography.caption.color}" },
      "h6-color": { "$value": "{color.text.subtle}" }
    }
  }
}

Run npm run build:tokens to regenerate the CSS custom properties.

Four things are worth noting before the components consume these.

All six heading levels are defined. It is tempting to stop at three, since most interfaces rarely go deeper. The level prop accepts 1 through 6, so stopping at three means <RudiHeading level={5}> renders correct semantics with no styling at all, and nothing in the type system warns you.

Every entry carries its own color. They all alias {color.text.default} today, which makes the tokens look redundant. They are not. A theme that wants slightly muted small headings, or a marketing surface where h1 picks up the brand color, changes one token instead of adding a modifier class to every heading in the application.

The key is line-height, not lineHeight. Style Dictionary’s CSS transform converts token paths to kebab-case, so both spellings would produce --rudi-typography-heading-1-line-height. Kebab-case keys match the generated custom property names, so the JSON reads the same as the CSS.

typography.code and typography.prose are large. The code group exists so inline code has one definition rather than six hardcoded values repeated across Text and Prose. The prose group carries 23 tokens because Prose styles raw HTML, and every element it touches needs a themeable value. Both are covered below.

Heading

The Heading component renders the correct semantic HTML element (<h1> through <h6>) based on a level prop. It also accepts a size prop for cases where the visual size needs to differ from the semantic level.

This distinction matters for accessibility. Screen reader users navigate by heading level to understand page structure. If every visually large heading is an <h1>, the document structure is flat and unhelpful. The level prop enforces correct document structure. The size prop lets you render an <h2> that looks like an <h1> when the design calls for it.

// src/typography/Heading/Heading.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './heading.css'

import type {
  RudiTypographyAlign,
  RudiTypographyTone,
  RudiTypographyWeight,
} from '@/typography/Text/Text'

export interface RudiHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
  level: 1 | 2 | 3 | 4 | 5 | 6
  size?: 1 | 2 | 3 | 4 | 5 | 6
  /** Font weight, overriding the size default. */
  weight?: RudiTypographyWeight
  /** Semantic text color. */
  tone?: RudiTypographyTone
  /** Text alignment. */
  align?: RudiTypographyAlign
  /** Removes the element's default block margin. */
  noMargin?: boolean
  children: React.ReactNode
  className?: string
}

export const RudiHeading = forwardRef<HTMLHeadingElement, RudiHeadingProps>(
  function RudiHeading(
    { level, size, weight, tone, align, noMargin, className, children, ...props },
    ref,
  ) {
    const Element = `h${level}` as const
    const visualSize = size ?? level

    return (
      <Element
        ref={ref}
        className={cn(
          `rudi-heading rudi-heading--${visualSize}`,
          weight && `rudi-heading--weight-${weight}`,
          tone && `rudi-heading--tone-${tone}`,
          align && `rudi-heading--align-${align}`,
          noMargin && 'rudi-heading--flush',
          className,
        )}
        {...props}
      >
        {children}
      </Element>
    )
  },
)

weight, tone, align, and noMargin are shared with Text, and their types are defined there. The Text section covers what each accepts; the important structural point is that one component owns the vocabulary and the other imports it, so the two can never drift apart.

The CSS applies typography tokens per heading size. All six levels get a block:

.rudi-heading--1 {
  font-size: var(--rudi-typography-heading-1-size);
  font-weight: var(--rudi-typography-heading-1-weight);
  line-height: var(--rudi-typography-heading-1-line-height);
  letter-spacing: var(--rudi-typography-heading-1-tracking);
  color: var(--rudi-typography-heading-1-color);
}

/* Repeat for --2 through --6, swapping the token index. */

Then the shared modifiers, which are the same set on Text with a different class prefix:

/* Weight modifiers */
.rudi-heading--weight-regular { font-weight: var(--rudi-font-weight-regular); }
.rudi-heading--weight-medium { font-weight: var(--rudi-font-weight-medium); }
.rudi-heading--weight-semibold { font-weight: var(--rudi-font-weight-semibold); }
.rudi-heading--weight-bold { font-weight: var(--rudi-font-weight-bold); }

/* Tone modifiers */
.rudi-heading--tone-default { color: var(--rudi-color-text-default); }
.rudi-heading--tone-subtle { color: var(--rudi-color-text-subtle); }
.rudi-heading--tone-disabled { color: var(--rudi-color-text-disabled); }
.rudi-heading--tone-brand { color: var(--rudi-color-brand-primary); }
.rudi-heading--tone-inverted { color: var(--rudi-color-text-on-inverted); }
.rudi-heading--tone-success { color: var(--rudi-color-feedback-success-text); }
.rudi-heading--tone-warning { color: var(--rudi-color-feedback-warning-text); }
.rudi-heading--tone-error { color: var(--rudi-color-feedback-error-text); }
.rudi-heading--tone-info { color: var(--rudi-color-feedback-info-text); }

/* Alignment modifiers */
.rudi-heading--align-start { text-align: start; }
.rudi-heading--align-center { text-align: center; }
.rudi-heading--align-end { text-align: end; }
.rudi-heading--align-justify { text-align: justify; }

/* Margin modifier */
.rudi-heading--flush { margin: 0; }

The tone modifiers are where the four-value feedback sets from chapter 3 pay off. tone="error" reads --rudi-color-feedback-error-text, the darker value, rather than --rudi-color-feedback-error, the saturated one. The saturated red is a good border and a poor body color; using it for text would fail contrast against a light surface at small sizes.

Heading Levels — open in Storybook

Text

The Text component covers non-heading text: body paragraphs, small text, captions, overlines, and inline code. The variant prop selects the typographic style, and the as prop controls the rendered element.

// src/typography/Text/Text.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './text.css'

export type RudiTypographyWeight = 'regular' | 'medium' | 'semibold' | 'bold'
export type RudiTypographyTone =
  | 'default'
  | 'subtle'
  | 'disabled'
  | 'brand'
  | 'inverted'
  | 'success'
  | 'warning'
  | 'error'
  | 'info'
export type RudiTypographyAlign = 'start' | 'center' | 'end' | 'justify'

export interface RudiTextProps extends React.HTMLAttributes<HTMLElement> {
  variant?: 'body' | 'body-sm' | 'caption' | 'overline' | 'code'
  as?: React.ElementType
  /** Font weight, overriding the variant default. */
  weight?: RudiTypographyWeight
  /** Semantic text color. */
  tone?: RudiTypographyTone
  /** Text alignment. */
  align?: RudiTypographyAlign
  /** Removes the element's default block margin. */
  noMargin?: boolean
  children: React.ReactNode
  className?: string
}

export const RudiText = forwardRef<HTMLElement, RudiTextProps>(function RudiText(
  {
    variant = 'body',
    as: Element = 'p',
    weight,
    tone,
    align,
    noMargin,
    className,
    children,
    ...props
  },
  ref,
) {
  return (
    <Element
      ref={ref}
      className={cn(
        `rudi-text rudi-text--${variant}`,
        weight && `rudi-text--weight-${weight}`,
        tone && `rudi-text--tone-${tone}`,
        align && `rudi-text--align-${align}`,
        noMargin && 'rudi-text--flush',
        className,
      )}
      {...props}
    >
      {children}
    </Element>
  )
})

Export those three type aliases. They are the shared typography vocabulary, and Heading imports them rather than redeclaring its own copies. Consumers get them from the package root too, which is what makes it possible to write a wrapper component that accepts a tone and passes it straight through.

Each type is a closed union for a reason. tone maps to semantic colors that exist in the token system, so tone="purple" fails at compile time instead of silently emitting a class with no rule behind it. The same applies to weight and align.

.rudi-text--body {
  font-size: var(--rudi-typography-body-size);
  font-weight: var(--rudi-typography-body-weight);
  line-height: var(--rudi-typography-body-line-height);
  color: var(--rudi-typography-body-color);
}

.rudi-text--body-sm {
  font-size: var(--rudi-typography-body-sm-size);
  font-weight: var(--rudi-typography-body-sm-weight);
  line-height: var(--rudi-typography-body-sm-line-height);
  color: var(--rudi-typography-body-sm-color);
}

.rudi-text--caption {
  font-size: var(--rudi-typography-caption-size);
  font-weight: var(--rudi-typography-caption-weight);
  line-height: var(--rudi-typography-caption-line-height);
  color: var(--rudi-typography-caption-color);
}

.rudi-text--overline {
  font-size: var(--rudi-typography-overline-size);
  font-weight: var(--rudi-typography-overline-weight);
  line-height: var(--rudi-typography-overline-line-height);
  letter-spacing: var(--rudi-typography-overline-tracking);
  color: var(--rudi-typography-overline-color);
  text-transform: uppercase;
}

.rudi-text--code {
  font-family: var(--rudi-typography-code-font-family);
  font-size: var(--rudi-typography-code-font-size);
  background: var(--rudi-typography-code-bg);
  padding: var(--rudi-typography-code-padding-y)
    var(--rudi-typography-code-padding-x);
  border-radius: var(--rudi-typography-code-radius);
}

Then the same four modifier groups as Heading, with the rudi-text-- prefix: weight-*, tone-*, align-*, and flush.

The code variant is the clearest argument for the typography.code token group. Written with literal values it needs a font stack, a size, a background, two padding values, and a radius, all hardcoded, and all repeated again inside Prose for inline <code> elements. As tokens they are defined once and both places read them.

Note that code sets font-size to a token whose value is 0.875em, not rem. Inline code sits inside running text, so it should scale with whatever text surrounds it. An absolute size would make code inside a heading look wrong.

Text Levels — open in Storybook

Prose

The Prose component solves the problem that Stack and Heading alone cannot: context-aware vertical rhythm in long-form content.

Stack applies uniform spacing between its children. But long-form content needs variable spacing. The gap above a heading should be larger because it signals a new section. The gap below a heading should be smaller because the heading belongs to what follows. Paragraphs need consistent but moderate spacing. Code blocks need extra breathing room.

Prose handles all of this with CSS selectors that target element-type relationships:

// src/typography/Prose/Prose.tsx
import { forwardRef } from 'react'
import { cn } from '@/utils/cn'
import './prose.css'

export interface RudiProseProps extends React.HTMLAttributes<HTMLElement> {
  size?: 'sm' | 'base' | 'lg'
  as?: React.ElementType
  children: React.ReactNode
  className?: string
}

export const RudiProse = forwardRef<HTMLElement, RudiProseProps>(function RudiProse(
  { size = 'base', as: Element = 'div', className, children, ...props },
  ref,
) {
  return (
    <Element
      ref={ref}
      className={cn(
        'rudi-prose',
        size !== 'base' && `rudi-prose--${size}`,
        className,
      )}
      {...props}
    >
      {children}
    </Element>
  )
})
.rudi-prose {
  --prose-space: var(--rudi-typography-prose-space, 1.5em);
  --prose-heading-before: var(--rudi-typography-prose-heading-before, 2em);
  --prose-heading-after: var(--rudi-typography-prose-heading-after, 0.75em);
  color: var(--rudi-typography-body-color);
  font-size: var(--rudi-typography-body-size);
  font-weight: var(--rudi-typography-body-weight);
  line-height: var(--rudi-typography-body-line-height);
}

/* Clear the browser's own block margins first. Every rule below sets
   margin-block-start explicitly, but a heading also carries a UA
   margin-block-end, and adjacent margins collapse to the larger of the two.
   Without this reset the UA value wins and heading-after never applies. */
.rudi-prose > * {
  margin-block: 0;
}

/* Base spacing between all adjacent elements */
.rudi-prose > * + * {
  margin-block-start: var(--prose-space);
}

/* More space before headings (they signal new sections) */
.rudi-prose > :is(h1, h2, h3, h4, h5, h6) {
  margin-block-start: var(--prose-heading-before);
}

/* Less space after headings (they belong to what follows) */
.rudi-prose > :is(h1, h2, h3, h4, h5, h6) + * {
  margin-block-start: var(--prose-heading-after);
}

/* Consecutive headings sit closer together */
.rudi-prose > :is(h1, h2, h3, h4, h5, h6) + :is(h1, h2, h3, h4, h5, h6) {
  margin-block-start: var(--prose-heading-after);
}

/* Extra breathing room around code blocks */
.rudi-prose > pre {
  margin-block-start: 2em;
}

.rudi-prose > pre + * {
  margin-block-start: 2em;
}

/* No top margin on the first child */
.rudi-prose > :first-child {
  margin-block-start: 0;
}

/* Size variants scale the base font size; em-based spacing adjusts proportionally */
.rudi-prose--sm {
  font-size: var(--rudi-typography-prose-size-sm);
}
.rudi-prose--lg {
  font-size: var(--rudi-typography-prose-size-lg);
}

The spacing values use em, not rem. This is the key detail. An em value scales with the element’s own font size. When you render <RudiProse size="sm"> inside a sidebar, the spacing contracts proportionally with the smaller text. When you render <RudiProse size="lg"> for a blog post, the spacing expands. The rhythm stays correct regardless of size.

That em argument only holds because .rudi-prose sets its own font-size rather than inheriting one. Without those four base declarations, Prose adopts whatever typography surrounds it, and the em spacing tracks a value you did not choose.

The consecutive-heading rule states outright what the heading + * rule already does. Both have the same specificity and the later one wins, so an <h3> directly following an <h2> gets --prose-heading-after either way. Keeping the explicit rule documents the intent, and protects it if someone reorders the stylesheet.

Prose styles raw HTML

Everything above is spacing, and spacing is roughly a fifth of what Prose actually does. The remaining rules style unclassed HTML elements, because that is the whole point of the component: content arrives from a CMS, a Markdown renderer, or an API as plain <h2>, <p>, <ul>, <blockquote>, and <table> tags with no class names to hook into.

The full stylesheet in the companion repository runs to about 240 lines and covers ten groups:

GroupElementsRepresentative tokens
Headingsh1 through h6--rudi-typography-heading-{n}-*, plus --rudi-typography-prose-h6-color
Linksa, a:hover--rudi-typography-prose-link-color, -link-hover-color
Inline emphasisstrong, em--rudi-typography-prose-strong-weight, -strong-color
Listsul, ol, li, nested listsindent and marker values
Blockquotesblockquote--rudi-typography-prose-blockquote-border, -blockquote-color
Inline codecode--rudi-typography-code-*, --rudi-typography-prose-code-bg
Code blockspre, pre code--rudi-typography-prose-pre-{bg,border,radius,font-size,line-height}
Ruleshr--rudi-typography-prose-hr-color
Tablestable, thead th, tbody tr, tbody td--rudi-typography-prose-table-border, -table-header-weight
Figuresfigure > img, figcaption--rudi-typography-prose-figure-radius, -caption-color

Two patterns are worth copying rather than reading past.

pre code resets what code sets. Inline code gets a tinted background, padding, and its own size; the same element inside a <pre> should get none of those, because the block already provides them:

.rudi-prose pre code {
  background: none;
  padding: 0;
  border-radius: 0;
  font-size: inherit;
}

And every one of these rules is scoped under .rudi-prose. A component library that styles bare h2 and table selectors globally is doing the same thing Tailwind’s preflight does, which chapter 1 rejected. Inside an opt-in wrapper the same rules are exactly what the consumer asked for.

Prose Levels — open in Storybook

Usage

<RudiProse as="article" size="base">
  <RudiHeading level={1}>Building accessible component libraries</RudiHeading>
  <RudiText>
    Component libraries are only as useful as their accessibility coverage. This
    article explores what that means in practice.
  </RudiText>

  <RudiHeading level={2}>The problem with retrofitting</RudiHeading>
  <RudiText>
    Adding accessibility after the fact costs more than building it in from the
    start. The reason is structural: accessible behavior affects component APIs,
    keyboard handling, and focus management.
  </RudiText>

  <RudiText>
    Consider a modal dialog. An accessible implementation requires focus
    trapping, Escape to close, scroll locking, and aria-modal. These behaviors
    are not cosmetic. They change how the component works.
  </RudiText>

  <pre>
    <code>{`const { dialogProps } = useDialog({}, ref);`}</code>
  </pre>

  <RudiText>
    React Aria provides these behaviors as hooks. The hook returns props that
    you spread onto your elements.
  </RudiText>
</RudiProse>

The headings get more space above (new section) and less space below (connected to the following paragraph). Consecutive paragraphs get standard spacing. The code block gets extra room. You don’t need manual spacing classes.

What’s next

You now have three typography components (Heading, Text, Prose) alongside eight layout primitives, all driven by the same token system. The library handles spatial arrangement, typographic consistency, and long-form content rhythm. The next chapter adds interactive components with React Aria.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.