What frontend developers get wrong about typography
Typography isn't decoration. It's the primary interface between your content and your reader. Most frontend engineers treat it as an afterthought — here's what changes when you take it seriously.
I want to start with a provocation: typography is 80% of web design.
Not a literal number — but if you systematically improved the typography of a mediocre design while leaving everything else unchanged, the result would be dramatically better. If you systematically degraded the typography of a beautiful design while leaving everything else unchanged, the result would feel broken.
Most frontend engineers know this is probably true, and then completely ignore it when writing CSS.
This is an attempt to change that.
The three things developers get wrong
1. Treating font-size as the primary lever
When developers think about typography, they reach for font-size. Make it bigger, make it smaller. This is the least interesting typographic control available.
The parameters that actually determine how text feels:
- Line height (
line-height) — the space between lines of text - Measure (line length) — how many characters fit on a line
- Letter spacing (
letter-spacing) — the space between letters - Font weight variation — using weight to create hierarchy
- Vertical rhythm — how type sits on a consistent spatial grid
Let me make this concrete. Here are two paragraphs of the same text, same font, same font-size:
/* Version A — default browser styles */
p {
font-size: 16px;
/* line-height: 1.2 (browser default) */
}
/* Version B — considered typography */
p {
font-size: 1rem;
line-height: 1.7;
max-width: 65ch;
letter-spacing: 0.01em;
}
Version A is technically identical in font size. Version B is dramatically more readable. The difference is line-height, max-width (controlling measure), and a tiny amount of letter spacing.
2. Ignoring the measure
The measure is the length of a line of text. In print typography, the ideal measure is 45–75 characters per line. On screen, 55–80 characters is a reasonable target.
Most websites either:
- Have text that spans the full viewport width on desktop (125+ characters per line), or
- Have no thought given to measure at all
Reading excessively long lines is cognitively taxing. The eye has to travel too far to find the start of the next line, leading to re-reading and fatigue.
The CSS unit ch (the width of the character "0" in the current font) is your friend:
.prose {
max-width: 65ch;
/* This adapts to the actual font — the measure stays right even if you
change font-family or font-size */
}
This is more accurate than a pixel value because ch scales with the font. 65ch with Inter at 18px is a different measure than 65ch with Georgia at 18px — but both will be approximately optimal for their respective typefaces.
3. Using a single typeface without variation
One of the hallmarks of amateur typography on the web is a single typeface used at the same weight throughout — just scaled up or down.
Good typographic hierarchy uses multiple variables simultaneously:
- Size (but less than you think)
- Weight (the most powerful hierarchy signal)
- Color/opacity (secondary text at lower opacity reads as secondary)
- Tracking/letter-spacing (tight for large display text, wide for small labels)
- Case (small caps and uppercase for labels and metadata)
- Typeface (a serif display face paired with a sans-serif body)
Here's how I approach typographic hierarchy in CSS:
/* Display heading — large, tight, heavy */
.heading-display {
font-family: var(--font-display);
font-size: clamp(2.5rem, 6vw, 5rem);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em; /* Tight tracking on large text */
color: var(--text-primary);
}
/* Section heading — medium, snug */
.heading-section {
font-family: var(--font-display);
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--text-primary);
}
/* Body text — comfortable, readable */
.body-text {
font-family: var(--font-body);
font-size: 1.0625rem; /* 17px */
font-weight: 400;
line-height: 1.7;
letter-spacing: 0.005em;
color: var(--text-secondary);
}
/* Metadata / labels — small, spaced, muted */
.label {
font-family: var(--font-body);
font-size: 0.6875rem; /* 11px */
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.1em; /* Wide tracking on small text */
text-transform: uppercase;
color: var(--text-tertiary);
}
Notice that the heading uses tighter letter-spacing as it gets larger, while the label uses wider letter-spacing as it gets smaller. This is a fundamental typographic principle: large text tracks tight, small text tracks wide.
Font loading: the performance dimension
Web typography has a performance cost that most developers either don't measure or accept as inevitable. Neither is right.
Variable fonts
A variable font is a single font file that contains an entire font family across a continuous axis of variation (weight, width, slant). Instead of loading separate files for Regular, Medium, SemiBold, and Bold, you load one file and control variation in CSS:
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-variable.woff2') format('woff2-variations');
font-weight: 100 900; /* Supports the full weight range */
font-display: swap;
}
/* Use any weight — it's all in one file */
.text-light { font-weight: 300; }
.text-regular { font-weight: 400; }
.text-medium { font-weight: 500; }
.text-bold { font-weight: 700; }
.text-black { font-weight: 900; }
A variable font file is typically 30–40% smaller than loading four separate static weight files.
The subset
For Latin-script sites, loading a full unicode font file is wasteful. Most modern fonts support hundreds of character ranges — Cyrillic, Greek, CJK, mathematical symbols — that your users will never need.
Use unicode-range in @font-face to load only the Latin subset:
@font-face {
font-family: 'MyFont';
src: url('/fonts/my-font-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
font-display: swap;
}
Google Fonts does this automatically by default. If you're self-hosting, tools like fonttools or glyphhanger can generate subset font files.
font-display: optional for body text
font-display: swap will show a system font flash, then swap to your web font when it loads. This causes a brief layout shift.
font-display: optional is more aggressive: if the font hasn't loaded within a short time window, the browser uses the system font and never swaps. No layout shift. The user sees the web font on their next visit (it's cached).
For body text, font-display: optional often produces a better experience than swap, particularly for users on slower connections.
The system font stack
Sometimes the right answer is no custom font at all.
System fonts are:
- Already installed on the device — zero download time, zero layout shift
- Optimised for the device's rendering engine
- Familiar to users in a subtle but real way
The modern system font stack:
body {
font-family:
-apple-system, /* San Francisco on macOS/iOS */
BlinkMacSystemFont, /* San Francisco on Chrome/macOS */
'Segoe UI', /* Windows */
system-ui, /* Generic system UI fallback */
sans-serif;
}
GitHub, Linear, and Notion all use system fonts for their application interfaces. It's not laziness — it's a considered choice to prioritise performance and familiarity over typographic distinction.
Use a custom typeface when typographic character is part of the brand experience. Use system fonts when you want the interface to feel native and fast.
Fluid typography
Fixed font sizes create awkward jumps at breakpoints. A 48px heading on desktop becomes 24px on mobile via a media query — two separate, disconnected sizes.
CSS clamp() enables fluid typography: a font size that scales smoothly across the viewport:
h1 {
/* At 320px viewport: 28px. At 1440px: 56px. Smooth curve between. */
font-size: clamp(1.75rem, 2.5vw + 1rem, 3.5rem);
}
The formula 2.5vw + 1rem creates a linear relationship between viewport width and font size. The clamp() function enforces minimum and maximum bounds, so the font never gets too small or too large.
This approach means your type system works across all viewport sizes with zero media queries for font-size.
Where to go from here
If you want to understand web typography more deeply, two resources that have shaped how I think:
Books:
- The Elements of Typographic Style by Robert Bringhurst — the canonical text on typography. Read it slowly.
- Thinking with Type by Ellen Lupton — more visual, more approachable, covers the same ground differently.
Tools:
- Fontshare — excellent free typefaces for commercial use
- type-scale.com — visualise typographic scales
- utopia.fyi — generate fluid type scales
The investment in understanding typography is one of the highest-ROI things a frontend developer can make. It elevates everything you build.
I write about the intersection of frontend engineering and design craft. If typography, performance, or design systems are problems you're thinking about, reach out.
Deepak
Full-Stack Engineer based in Germany. I write about performance, Angular, Node.js, design systems, and the craft of building exceptional web experiences.
Book a 15-min call →