A referential guide on best web typographic practices.
Introduction
Typography is more than just what fonts you use. It is everything that has to do with how text looks – font size, line length, colour, and subtler things like the whitespace around a text. Good typography sets the tone of your written message and helps reinforce its meaning and context.
This handbook is based on Kenneth Wang’s original Typography Handbook, updated and restyled as an independent reference. It is meant to be short and concise – a quick reference rather than an in-depth guide. For deeper explanations, follow the Further Reading links in each section.
Typographic Design
Visual hierarchy is the concept of organizing elements on a page in a way that establishes an order of importance, allowing readers to easily navigate the page and find relevant content. When done correctly, it should guide the reader’s eye throughout different sections of the page. Visual hierarchy is very prevalent in typography, and forms the basis of typographic design theory.
Consider the following typographic design of Alice’s Adventures In Wonderland, which clearly establishes a visual hierarchy:
Visual hierarchy can be broken down into 5 different parts:
Gestalt principles, or gestalt laws, are rules of the organization of perceptual scenes. When we look at the world, we usually perceive complex scenes composed of many groups of objects on some background, with the objects themselves consisting of parts, which may be composed of smaller parts, etc. Scholarpedia
The two most important Gestalt Laws to understand in typography are the Law of Proximity and the Law of Similarity.
The Law of Proximity states that humans perceive objects that are closer together as related objects, and vice versa. In typographic design, “proximity” refers to the amount of whitespace created by line height, margin, and padding. There should be a distinctive and noticeable amount of additional whitespace between two different sections.
The Gestalt Law of Similarity states that entities that look similar tend to be grouped together. In typography, this means keeping your styles consistent on elements that serve the same function. Elements with different functions should not look similar to one another.
Fonts
Choosing fonts is a creative and emotional process. Different fonts convey different feelings, and you want a font that complements the tone of your text.
In 2026 you only need woff2. EOT, TTF, and SVG formats were required for older browsers that no longer exist. The modern @font-face declaration is much simpler:
@font-face {
font-family: 'Your Font';
src: url('/fonts/your-font-300.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Your Font';
src: url('/fonts/your-font-300-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face declaration, browsers synthesise italics by skewing the upright letterforms – the result is never as good as a designed italic.font-family name, differentiated only by font-weight and font-style.@font-face declaration can cover the entire weight range.woff2 files from other formats.The font-display property – added to the @font-face rule – is now the standard way to control font loading behaviour. It replaced the need for JavaScript-based solutions in most cases.
@font-face {
font-family: 'Your Font';
src: url('/fonts/your-font.woff2') format('woff2');
font-display: swap;
}
The key values for font-display are:
swap – shows a fallback font immediately, swaps to the custom font when ready. The recommended default for most body text. Causes a brief layout shift on load but keeps content readable immediately.optional – gives the font a very short window to load (around 100ms). If it doesn’t arrive in time, the fallback is used for the entire session with no swap. Best for non-critical decorative fonts where a flash would be jarring.block – hides text briefly while the font loads, then renders it. Avoid unless the font is absolutely central to the experience.For fonts used above the fold – your body font, your main heading – add a <link rel="preload"> in the <head> to tell the browser to fetch the file early, before it’s discovered in the CSS:
<link rel="preload"
href="/fonts/your-font-400.woff2"
as="font"
type="font/woff2"
crossorigin>
crossorigin attribute is required even for self-hosted fonts.preload with font-display: optional for a near-zero layout shift experience.The visual jump when a fallback font swaps to a custom font – the FOUT – can be minimised by making your fallback font metrics as close as possible to your custom font. The @font-face descriptor size-adjust lets you scale the fallback to match:
@font-face {
font-family: 'Fallback for Inter';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Fallback for Inter', sans-serif;
}
Use Fallback Font Generator or Font Style Matcher to find the right override values for your specific font pairing.
OpenType features are typographic options built into the font file itself – things like ligatures, kerning, small caps, and oldstyle figures. They are enabled via CSS.
p {
font-kerning: auto;
font-variant-ligatures: common-ligatures contextual;
font-optical-sizing: auto;
font-feature-settings: "kern", "liga", "clig", "calt";
}
kern, ligatures liga, contextual ligatures clig, and contextual alternatives calt should always be enabled.font-optical-sizing: auto – modern browsers and variable fonts support this. It tells the font to adjust letterform details based on the rendered size, giving you optically appropriate stroke weights at both display and caption sizes. Enable it on everything.font-kerning, font-variant-*) in preference to font-feature-settings where possible – they cascade better and are easier to read.Variable fonts are a single font file that contains a continuous range of weights, widths, or other axes – rather than separate files for each weight. A variable font with a weight axis from 100 to 900 replaces nine separate font files with one.
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Use any weight along the axis */
h1 { font-weight: 650; }
p { font-weight: 390; }
small { font-weight: 320; }
wght), width (wdth), italic (ital), slant (slnt), and optical size (opsz) are the standard registered axes. Many fonts also offer custom axes.font-variation-settings for custom or non-standard axes: font-variation-settings: 'wght' 450, 'wdth' 85;System fonts – the fonts already installed on the user’s device – have improved dramatically. San Francisco on macOS and iOS, Segoe UI on Windows, and Roboto on Android are all well-crafted, highly legible typefaces. Using them means zero network requests, zero font loading flash, and text that feels native to the platform.
/* Modern system font stack */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Helvetica Neue', Arial, sans-serif;
}
/* Or just: */
body {
font-family: system-ui, sans-serif;
}
system-ui is now supported across all modern browsers and resolves to the platform’s default UI font. It is usually sufficient on its own with a sans-serif fallback.Web Style Guide
Use relative units whenever possible.
html { font-size: 100% }
p { font-size: 1em }
@media (min-width: 64em) {
html { font-size: 112.5%; }
}
font-size: 100% listens to the browser’s font size settings. By default in most browsers, this places 1em at 16px.font-size of the html element also affects every em and rem element – useful for responsive web design.font-size: 100% and 1em.rem and em for font-size; use rem, em, or % for positioning; use em for media queries.The container, also known as the wrapper, encloses one or more other elements. It allows for grouping elements semantically, cosmetically, or in layout.
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
.container {
max-width: 67rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
box-sizing: border-box. Click here for more information.max-width with left and right padding is an easy way to create a mobile-friendly container.The measure is the length of a line of text. It is one of the most important – and most overlooked – factors in readability. A line that is too long causes the eye to struggle to find the start of the next line; too short and the constant returns become fatiguing.
The classical typographic guideline is 45–75 characters per line (CPL) for a single-column body text, with 66 characters often cited as the ideal. For multi-column layouts, 40–50 CPL per column is more appropriate.
/* A practical measure using ch units */
/* 1ch ≈ the width of the "0" character */
.content {
max-width: 65ch; /* approximately 65 characters wide */
padding-inline: 1.5rem;
}
ch unit is based on the width of the 0 character in the current font. It’s not a perfect measure of average character width – but it’s a reliable and practical approximation.line-height by roughly 0.1 for every 10 characters above 65 CPL.Use a modular scale to establish the proportional relationships between your type sizes – the principle is unchanged from 2016. What has changed is how you implement it responsively.
clamp()The clamp() function – now universally supported – replaces the responsive modular scale approach. Instead of defining separate type sizes at different breakpoints, you define a minimum size, a maximum size, and a fluid value that scales between them based on viewport width. This eliminates the jarring jumps at breakpoints entirely.
/* clamp(minimum, preferred, maximum) */
h1 {
/* 2rem at small viewports, scales up to 4rem, fluid in between */
font-size: clamp(2rem, 5vw + 1rem, 4rem);
}
h2 {
font-size: clamp(1.5rem, 3vw + 1rem, 2.5rem);
}
p {
/* Body text: 1rem minimum, never exceeds 1.25rem */
font-size: clamp(1rem, 1.5vw + 0.5rem, 1.25rem);
}
5vw + 1rem) is the fluid part – it grows with the viewport. Adding a rem component ensures it doesn’t become too small on narrow screens.clamp() values for an entire scale at once.clamp() handle the in-between.rem minimum, never a viewport-only minimum – this ensures accessibility for users who have increased their browser’s base font size.Vertical spacing is created by line-height, margin, and padding. line-height should be unitless. Apply margins in a single direction on textual elements, preferably margin-bottom. Adhere to the Law of proximity.
Vertical rhythm keeps vertical spaces between elements consistent, creating a visually relaxing experience and a feeling of familiarity.
body { line-height: 1.4; }
p {
font-size: 1.25em;
margin-bottom: 1.75rem; /* 1.4 × 1.25 */
}
h1 { font-size: 3em; margin-bottom: 3.5rem; }
h2 { font-size: 2em; margin-bottom: 1.75rem; }
h3 { font-size: 1.5em; margin-bottom: 1.75rem; }
.page-container { padding: 3.5rem 2rem; }
The bottom aligned baseline grid is a stricter implementation of vertical rhythm. In web, text is vertically aligned to the center of the line-height. In print, text is aligned to the bottom of the baseline grid instead.
In modern CSS, the lh unit – equal to the current element’s line-height – makes baseline-relative spacing much simpler without needing a preprocessor library. Remember that vertical rhythm is a guideline, not a rule. It does not need to be pixel perfect.
Color provides a huge visual distinction and is an important part of typography.
#000 as your body text color. Instead, use a very dark gray such as #333.In a printed document, don’t underline. Ever. It’s ugly and it makes text harder to read. Practical Typography
On the web, underlines serve a specific purpose: indicating hyperlinks. The browser default underline sits too close to the baseline, cuts through descenders, and carries a fixed weight regardless of font size. Modern CSS gives you precise control over all three.
a {
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: auto;
}
Using em units for text-decoration-thickness means the underline scales proportionally with the font size. 0.08em is roughly 1–2px at body sizes but naturally heavier at display sizes where a hairline would look too light.
text-decoration-skip-ink: auto tells the browser to interrupt the underline where it passes through descender strokes on letters like g, j, p, q, and y. This is the single most important underline improvement – without it, descenders appear to cut through the line at any size.
Moving the underline below the baseline with text-underline-offset creates breathing room between text and line. 0.12em is a comfortable starting point – enough separation to be legible, close enough to remain clearly associated with the text.
Softening the underline colour creates a quieter default state, with a stronger underline on hover:
a {
text-decoration-thickness: 0.08em;
text-underline-offset: 0.12em;
text-decoration-skip-ink: auto;
text-decoration-color: rgba(0, 0, 0, 0.35);
color: inherit;
}
a:hover {
text-decoration-color: currentColor;
}
For body copy where you want links to blend into the text without disappearing entirely, combining a muted underline colour with color: inherit is the most readable approach. Reserve underlines for hyperlinks only – using them for decorative emphasis causes confusion.
CSS now offers native control over how text wraps – solving two long-standing typographic problems that previously required JavaScript or manual intervention.
text-wrap: balanceBalanced wrapping distributes text evenly across lines, eliminating the single short word – the “widow” – that often appears alone on the last line of a heading.
h1, h2, h3, h4, blockquote {
text-wrap: balance;
}
text-wrap: prettyPretty wrapping is designed for body copy. Rather than balancing the whole block, it focuses specifically on the last few lines – avoiding orphaned words at the end of paragraphs without the performance cost of full balance.
p, li {
text-wrap: pretty;
}
balance – check current compatibility before relying on it.balance on headings and pretty on paragraphs replaces most cases where you would previously have manually inserted or <wbr> to fix wrapping.Colour contrast between text and background is a legal and ethical requirement in most contexts, not an optional consideration. The Web Content Accessibility Guidelines (WCAG) define minimum contrast ratios, and meeting them is expected in professional work.
#000 on #fff creates uncomfortable contrast for users with dyslexia or visual sensitivity. A dark grey like #161616 or #333 is easier to read at length with no accessibility cost.The Advanced Perceptual Contrast Algorithm (APCA) is a newer contrast model that better reflects how human vision actually perceives contrast, accounting for font size, weight, and polarity (light-on-dark vs dark-on-light). It is proposed for WCAG 3.0. While not yet a formal standard, it is worth understanding – particularly for light-weight or small type where WCAG 2.x can be misleading in both directions.
Dark mode is not simply an inverted colour scheme – it requires deliberate typographic adjustments. Text that reads well on a white background often becomes uncomfortable in dark mode if you simply swap the colours.
Pure white text on a pure black background creates an extreme contrast that causes halation – a perceived glow or bloom around the letterforms that makes extended reading fatiguing. Soften both ends of the scale.
@media (prefers-color-scheme: dark) {
body {
background-color: #121212; /* not pure black */
color: #e8e8e8; /* not pure white */
}
}
#121212–#1a1a1a and a text colour of #e0e0e0–#f0f0f0 provides comfortable contrast without halation.Light text on a dark background appears thinner and lighter than the same text on a white background – a perceptual effect caused by how the eye processes light. To maintain visual consistency across modes, you may need to increase font weight slightly in dark mode.
@media (prefers-color-scheme: dark) {
body {
font-weight: 400;
}
/* If using a variable font, fine-tune the weight axis */
body {
font-variation-settings: 'wght' 420;
}
}
Dark backgrounds can make text feel more compressed. Slightly increased line-height or paragraph spacing can improve readability, though this is a minor consideration compared to contrast and weight.
@media (prefers-color-scheme: dark) {
p {
line-height: 1.6; /* vs 1.5 in light mode */
}
}
Use the prefers-color-scheme media query to detect the user’s system preference. Define your colour tokens as CSS custom properties so they can be swapped cleanly.
:root {
--bg: #ffffff;
--text: #161616;
--text-muted: #525252;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #e8e8e8;
--text-muted: #a0a0a0;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
Conclusion
Good web typography is still harder than it should be. The fundamentals here – hierarchy, rhythm, scale, color, loading – don’t change much, but the implementation details do. This edition attempts to keep those details current while staying true to the spirit of the original work.
Further reading is the real education. The resources listed throughout are the ones worth your time.