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

With typeset

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

With typeset

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

With typeset

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

With typeset

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

With typeset

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.
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.
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.
: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.
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.
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.
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.
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.
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.
'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;