A resource for designers and developers

Web
Typography

Typography is the foundation of great design. It shapes how we read, how we feel, and how we understand. This is a practical guide to getting it right on the web.

01 -- Typographic Rules

Clean Text, Automatically

Five rules that transform raw text into professionally typeset copy. Each function uses non-breaking spaces to control line breaks without altering content.

No Orphans

The last word of a paragraph shouldn't sit alone on its own line. Typeset binds it to the word before it.

Default

Default browser rendering

With typeset

With typeset applied
TypeScript
export function preventOrphans(text: string): string {
  const i = text.lastIndexOf(" ");
  if (i === -1) return text;
  return text.slice(0, i) + "\u00A0" + text.slice(i + 1);
}

Sentence-Start Protection

When a new sentence starts near the end of a line, the first word can get stranded alone. Typeset keeps the first two words of a sentence together.

Default

Default browser rendering

With typeset

With typeset applied
TypeScript
export function protectSentenceStart(text: string): string {
  return text.replace(/([.!?])\s+(\w+)\s+/g, "$1 $2\u00A0");
}

Sentence-End Protection

Short words like "it" "to" and "so" shouldn't dangle at the end of a sentence on their own line. They get pulled back to the previous line.

Default

Default browser rendering

With typeset

With typeset applied
TypeScript
export function protectSentenceEnd(text: string): string {
  return text.replace(/\s+(\w{1,3})([.!?])/g, "\u00A0$1$2");
}

Rag Smoothing

Without rag control, line lengths vary wildly — one line barely reaches half the column while the next fills it completely. Smoothing uses letter-spacing only to close 75% of the gap on short lines. A ResizeObserver recalculates whenever the container changes width, so it works with fluid layouts.

Default

Default browser rendering

With typeset

With typeset applied
TypeScript
export function smoothRag(el: HTMLElement): () => void {
  function apply() {
    clearSpans(el);             // remove previous adjustments
    const lines = getLines(el); // detect breaks via Range API
    const maxW = Math.max(...lines.slice(0,-1).map(l => l.width));
    lines.slice(0,-1).forEach(line => {
      const gap = maxW - line.width;
      if (gap < 5) return;
      // Letter-spacing only, cap 0.35px/char
      const ls = Math.min(0.35, (gap * 0.75) / line.text.length);
      wrapLine(line, `letter-spacing:${ls.toFixed(3)}px`);
    });
  }
  apply();
  const ro = new ResizeObserver(() =>
    requestAnimationFrame(apply)
  );
  ro.observe(el);
  return () => ro.disconnect(); // cleanup
}

Short Word Binding

Prepositions and articles like "of" "in" "a" and "the" look wrong sitting alone at the end of a line. Typeset binds them to the next word so they always travel together.

Default

Default browser rendering

With typeset

With typeset applied
TypeScript
export function bindShortWords(text: string): string {
  return text.replace(
    /\s(a|an|the|in|on|at|to|by|of|or)\s/gi,
    (m, w) => ` ${w}\u00A0`
  );
}

02 -- Font Pairings

Curated Combinations

12 handpicked font pairings, all loaded from Google Fonts. Each pair is shown with a live preview. Copy the CSS to use them in your project.

Editorial

Playfair Display + Source Sans Pro

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Modern Editorial

Inter + Lora

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Tech Meets Classic

Space Grotesk + Crimson Pro

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Google's Own

DM Serif Display + DM Sans

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Luxury

Cormorant Garamond + Fira Sans

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Startup Meets Tradition

Sora + Merriweather

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Literary

Libre Baskerville + Nunito Sans

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Bold Contrast

Oswald + EB Garamond

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Clean and Warm

Raleway + Bitter

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Functional Elegance

Work Sans + Spectral

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Old World, New Clarity

EB Garamond + Inter

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

Refined and Quiet

Spectral + DM Sans

The Art of Visual Hierarchy

Good typography is invisible. Great typography speaks to the reader without ever being noticed. It carries meaning through form, guides the eye with rhythm, and transforms raw content into an experience worth having.

03 -- Typography Tips

The Details That Matter

Practical CSS techniques for better reading experiences. Each tip is something you can apply to your next project today.

Line Height

Body text reads best at 1.5 to 1.7 line-height. Headings can be tighter -- 1.1 to 1.3. Never leave line-height at the browser default of 1.2 for body copy.

CSS
body { line-height: 1.6; }
h1, h2, h3 { line-height: 1.15; }

Measure (Line Length)

The ideal line length for comfortable reading is 45 to 75 characters. Too wide and the eye loses its place; too narrow and the rhythm breaks with constant line returns.

CSS
p { max-width: 65ch; }

Vertical Rhythm

Establish a base unit (e.g. 1.5rem) and derive all spacing from it. Margins, padding, and line-heights that share a common denominator create visual harmony.

CSS
:root { --rhythm: 1.5rem; }
p { margin-bottom: var(--rhythm); }
h2 { margin-top: calc(var(--rhythm) * 2); }

Responsive Type Scales

Use clamp() for fluid typography that scales smoothly between breakpoints. No more jagged media-query jumps -- just continuous, proportional scaling.

CSS
h1 { font-size: clamp(2rem, 5vw + 1rem, 4.5rem); }
p  { font-size: clamp(1rem, 1vw + 0.75rem, 1.25rem); }

text-wrap: balance and pretty

CSS now supports native text wrapping control. Use 'balance' on headings to even out line lengths, and 'pretty' on body text to avoid orphans. Browser support is growing fast.

CSS
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }

font-feature-settings

Unlock hidden typographic features: ligatures smooth letter connections, oldstyle numerals blend into body text, and tabular figures align in tables.

CSS
body {
  font-feature-settings: "liga" 1, "calt" 1;
}
.body-numerals {
  font-feature-settings: "onum" 1;
}
.table-numerals {
  font-feature-settings: "tnum" 1;
}

Orphans and Widows

CSS orphans and widows properties control how many lines appear at the bottom and top of page breaks. For web, combine with text-wrap: pretty and JavaScript solutions like typeset.ts.

CSS
p {
  orphans: 2;
  widows: 2;
  text-wrap: pretty;
}

Optical Margin Alignment

Punctuation and certain letterforms (T, V, W, quotation marks) create visual indentation. hanging-punctuation aligns text to the visual edge rather than the geometric one.

CSS
p {
  hanging-punctuation: first last;
}
blockquote {
  hanging-punctuation: first;
}

04 -- The Utility

typeset.ts

Drop this single file into any TypeScript project. Call typeset(text) to apply all five rules at once, or use individual functions for granular control.

typeset.ts
'use client';

/**
 * typeset.ts — Typographic refinement utility
 * 
 * Applies professional typographic rules to text elements:
 * 
 * Rule 1: No orphans — last line must have at least 2 words
 * Rule 2: Sentence-start protection — if a new sentence starts and only 1 word
 *         fits on the remaining line, push it to the next line
 * Rule 3: Sentence-end protection — if the last word of a sentence would be
 *         alone on a new line, bring a companion with it
 * Rule 4: Rag smoothing — if a line's last word juts out 3+ chars past the
 *         line below, knock it down for a smoother right edge
 * 
 * Usage:
 *   typeset(element)                    — process a single element
 *   typesetAll(selector)                — process all matching elements
 *   <Typeset> wrapper component         — React component
 */

const NBSP = '\u00A0'; // non-breaking space
const HAIR = '\u200A'; // hair space (invisible, used as marker)

/**
 * Detect sentence boundaries
 */
const isSentenceEnd = (word: string) =>
  /[.!?]$/.test(word) || /[.!?]["'\u201D\u2019]$/.test(word);

/**
 * Insert non-breaking spaces to enforce typographic rules.
 * Works by analyzing word groups and binding words that must stay together.
 */
export function typesetText(text: string): string {
  if (!text || text.length < 10) return text;

  const words = text.split(/\s+/).filter(Boolean);
  if (words.length < 3) return text;

  const result: string[] = [];

  for (let i = 0; i < words.length; i++) {
    const word = words[i];
    const prevWord = i > 0 ? words[i - 1] : null;
    const nextWord = i < words.length - 1 ? words[i + 1] : null;

    // Rule 1: Last two words always bound together (no orphans)
    if (i === words.length - 2) {
      result.push(word + NBSP + words[i + 1]);
      break;
    }

    // Rule 2: If previous word ends a sentence, bind this word with the next
    // (don't let a sentence start be alone at end of a line)
    // Catches: "stage. Tempo sets" → "stage. Tempo\u00A0sets"
    if (prevWord && isSentenceEnd(prevWord) && nextWord && !isSentenceEnd(word)) {
      if (word.length <= 6) {
        result.push(word + NBSP + words[i + 1]);
        i++; // skip next word, already consumed
        continue;
      }
    }

    // Rule 3: If this word ends a sentence/clause and it's short (1-5 chars),
    // bind it with the previous word so it doesn't dangle alone
    // Catches: "out." "out," "go." "it," "way" before punctuated words, etc.
    const hasTrailingPunct = /[.!?,;:]$/.test(word);
    if (hasTrailingPunct && word.length <= 7 && result.length > 0) {
      const last = result.pop()!;
      result.push(last + NBSP + word);
      continue;
    }

    // Rule 3b: If the NEXT word has trailing punctuation and is short,
    // bind this word + next together (e.g. "way out," stays together)
    if (nextWord && /[.!?,;:]$/.test(nextWord) && nextWord.length <= 5 && i < words.length - 2) {
      result.push(word + NBSP + words[i + 1]);
      i++;
      continue;
    }

    // Rule: Bind prepositions/articles with the next word
    // (prevents dangling "a", "to", "in", "of", "the", "is", "it", etc.)
    const shortWords = ['a', 'an', 'the', 'to', 'in', 'on', 'of', 'is', 'it', 'or', 'at', 'by', 'if', 'no', 'so', 'up', 'as', 'we', 'my', 'do', 'be'];
    // Only bind if the word has NO trailing punctuation (skip "of," "in," etc. in lists)
    if (shortWords.includes(word.toLowerCase()) && nextWord && !/[,;:.!?]$/.test(word)) {
      // Bind to BOTH previous and next word — prevents "of" from being at a line break
      // e.g. "center of gravity" becomes "center\u00A0of\u00A0gravity"
      if (result.length > 0) {
        const prev = result.pop()!;
        result.push(prev + NBSP + word + NBSP + words[i + 1]);
      } else {
        result.push(word + NBSP + words[i + 1]);
      }
      i++;
      continue;
    }

    result.push(word);
  }

  return result.join(' ');
}

/**
 * Apply typographic rules to a DOM element's text content.
 * Processes text nodes recursively.
 */
export function typeset(element: HTMLElement): void {
  if (!element) return;

  const walker = document.createTreeWalker(
    element,
    NodeFilter.SHOW_TEXT,
    null
  );

  const textNodes: Text[] = [];
  let node: Node | null;
  while ((node = walker.nextNode())) {
    textNodes.push(node as Text);
  }

  for (const textNode of textNodes) {
    const original = textNode.textContent;
    if (!original || original.trim().length < 10) continue;
    // Preserve leading/trailing whitespace (critical around inline elements like <strong>)
    const leadingSpace = original.match(/^\s*/)?.[0] || '';
    const trailingSpace = original.match(/\s*$/)?.[0] || '';
    const processed = typesetText(original.trim());
    textNode.textContent = leadingSpace + processed + trailingSpace;
  }
}

/**
 * Apply typographic rules to all elements matching a selector.
 */
export function typesetAll(selector: string): void {
  const elements = document.querySelectorAll<HTMLElement>(selector);
  elements.forEach(typeset);
}

/**
 * React hook: apply typeset to a ref on mount/update
 */
export function useTypeset(ref: React.RefObject<HTMLElement | null>, deps: any[] = []) {
  if (typeof window === 'undefined') return;

  // Use requestAnimationFrame to run after render
  const run = () => {
    requestAnimationFrame(() => {
      if (ref.current) typeset(ref.current);
    });
  };

  // MutationObserver approach for dynamic content
  if (ref.current) {
    run();
  }
}

/**
 * Rag smoothing — uses letter-spacing only to even out the right edge.
 * Attaches a ResizeObserver so it recalculates on container resize.
 * Returns a cleanup function to disconnect the observer.
 */
export function smoothRag(el: HTMLElement): () => void {
  let frame: number | null = null;

  function apply() {
    // Clear previous adjustments
    el.querySelectorAll<HTMLElement>('[data-rag]').forEach(span => {
      span.replaceWith(...span.childNodes);
    });
    el.normalize(); // merge adjacent text nodes

    const text = el.textContent || '';
    if (!text.trim()) return;

    // Detect lines via Range API
    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
    const textNodes: Text[] = [];
    let n: Node | null;
    while ((n = walker.nextNode())) textNodes.push(n as Text);
    if (!textNodes.length) return;

    const range = document.createRange();
    const lines: { startNode: Text; startOffset: number; endNode: Text; endOffset: number; width: number; text: string }[] = [];
    let lastTop = -1;
    let lineStart = { node: textNodes[0], offset: 0 };
    let lineText = '';

    for (const tn of textNodes) {
      for (let i = 0; i < (tn.textContent?.length || 0); i++) {
        range.setStart(tn, i);
        range.setEnd(tn, Math.min(i + 1, tn.textContent!.length));
        const rect = range.getBoundingClientRect();
        if (lastTop !== -1 && Math.abs(rect.top - lastTop) > 3 && lineText.trim()) {
          // End of previous line
          range.setStart(lineStart.node, lineStart.offset);
          range.setEnd(tn, i);
          lines.push({
            startNode: lineStart.node, startOffset: lineStart.offset,
            endNode: tn, endOffset: i,
            width: range.getBoundingClientRect().width,
            text: lineText.trim()
          });
          lineStart = { node: tn, offset: i };
          lineText = '';
        }
        lineText += tn.textContent![i];
        lastTop = rect.top;
      }
    }
    // Last line
    if (lineText.trim()) {
      const lastTn = textNodes[textNodes.length - 1];
      range.setStart(lineStart.node, lineStart.offset);
      range.setEnd(lastTn, lastTn.textContent!.length);
      lines.push({
        startNode: lineStart.node, startOffset: lineStart.offset,
        endNode: lastTn, endOffset: lastTn.textContent!.length,
        width: range.getBoundingClientRect().width,
        text: lineText.trim()
      });
    }

    if (lines.length < 2) return;

    const nonLast = lines.slice(0, -1);
    const maxW = Math.max(...nonLast.map(l => l.width));

    // Apply letter-spacing to short lines by wrapping in spans
    for (let i = 0; i < lines.length - 1; i++) {
      const line = lines[i];
      const gap = maxW - line.width;
      if (gap < 5) continue;
      const chars = line.text.length;
      if (!chars) continue;
      const ls = Math.min(0.35, (gap * 0.75) / chars);
      if (ls < 0.02) continue;

      // Wrap line content in a styled span
      range.setStart(line.startNode, line.startOffset);
      range.setEnd(line.endNode, line.endOffset);
      const span = document.createElement('span');
      span.setAttribute('data-rag', '');
      span.style.letterSpacing = `${ls.toFixed(3)}px`;
      range.surroundContents(span);
    }
  }

  // Initial pass
  apply();

  // Re-run on resize with debounce
  const ro = new ResizeObserver(() => {
    if (frame) cancelAnimationFrame(frame);
    frame = requestAnimationFrame(apply);
  });
  ro.observe(el);

  return () => {
    ro.disconnect();
    if (frame) cancelAnimationFrame(frame);
  };
}

export default typeset;