Frontend Typography Best Practices for Web Developers
I spent hours tweaking font sizes on my latest project, only to realize I had created a mess. My CSS had 12 different font sizes. My headings looked disconnected from my body text. And don’t get me started on the layout shifts when my web fonts finally loaded.
Here’s what I did wrong, and what I learned about frontend typography.
The Problem: Typography Chaos
I opened my stylesheet and found this disaster:
/* DON'T DO THIS */h1 { font-size: 32px; }h2 { font-size: 26px; }h3 { font-size: 22px; }h4 { font-size: 19px; }h5 { font-size: 17px; }h6 { font-size: 15px; }.body-text { font-size: 16px; }.small-text { font-size: 14px; }.tiny-text { font-size: 13px; }.caption { font-size: 12px; }.meta { font-size: 11px; }.legal { font-size: 10px; }Twelve font sizes. No relationship between them. No rhythm. And I was using Inter for everything because “it’s what everyone uses.”
Principle 1: Fewer Sizes, More Contrast
The first thing I learned: typography isn’t about having options, it’s about having constraints.
A 5-size modular scale works better than 12 random sizes:
:root { /* Modular scale with 1.25 ratio */ --text-xs: 0.75rem; /* 12px - captions, labels */ --text-sm: 0.875rem; /* 14px - secondary text */ --text-base: 1rem; /* 16px - body text */ --text-lg: 1.25rem; /* 20px - lead paragraphs */ --text-xl: 2rem; /* 32px - headings */}The key insight: contrast creates hierarchy, not incremental steps. A jump from 16px to 20px means something. A jump from 16px to 17px is just noise.
I tried a 1.125 ratio first, but it felt too subtle:
/* TOO SUBTLE - hard to distinguish levels */--text-sm: 0.889rem; /* 14.2px */--text-base: 1rem; /* 16px */--text-lg: 1.125rem; /* 18px */--text-xl: 1.266rem; /* 20.3px */Switching to 1.25-1.5 ratio made the hierarchy clear immediately.
Principle 2: Stop Using Inter and Roboto
I know, I know. Inter is well-designed. Roboto is everywhere. But when every project uses the same fonts, everything looks the same.
I explored alternatives:
| Overused Font | Better Alternatives |
|---|---|
| Inter | Instrument Sans, Plus Jakarta Sans, Outfit |
| Roboto | Onest, Figtree, Urbanist |
| System default | Fraunces, Newsreader, Lora (editorial) |
For my technical blog, I chose Plus Jakarta Sans for UI and JetBrains Mono for code. The result? The site feels distinctive instead of generic.
Principle 3: Match Fallback Font Metrics
The most annoying problem I encountered: layout shift when fonts loaded.
My page would render with Arial, then shift when Plus Jakarta Sans loaded. Users would accidentally click wrong buttons.
The fix: define a fallback font with matched metrics:
@font-face { font-family: 'Plus Jakarta Sans Fallback'; src: local('Arial'); size-adjust: 105%; ascent-override: 90%; descent-override: 20%; line-gap-override: 10%;}
body { font-family: 'Plus Jakarta Sans', 'Plus Jakarta Sans Fallback', sans-serif;}Finding the right values took trial and error. I compared screenshots side-by-side until the shift became imperceptible.
Principle 4: Respect User Preferences
I almost made a critical mistake: setting fixed line-heights that ignored user zoom.
When users zoom their browser, they need more line-height for readability. My original approach broke this:
/* BAD: Ignores user preferences */p { line-height: 24px; /* Fixed pixel value */ font-size: 16px;}The fix: use unitless line-height that scales with font-size:
/* GOOD: Scales with font size */p { line-height: 1.5; /* Unitless - scales with font-size */ font-size: 1rem;}Now when users zoom, the line-height scales proportionally.
Principle 5: Vertical Rhythm
I used to add random margins everywhere. Then I learned about vertical rhythm: line-height should be the base unit for ALL vertical spacing.
If my base line-height is 24px (1.5 × 16px), all vertical spacing should be multiples of 24px:
:root { --line-height: 1.5; --baseline: calc(1rem * var(--line-height)); /* 24px */}
p { margin-bottom: var(--baseline); /* 24px - one line */}
h2 { margin-top: calc(var(--baseline) * 2); /* 48px - two lines */ margin-bottom: var(--baseline); /* 24px - one line */}The result: everything aligns to an invisible grid. The page feels more cohesive.
Principle 6: Line Length for Readability
I noticed readers struggled with my wide paragraphs. The lines were too long to track easily.
The fix: use ch units to control line length:
article { max-width: 75ch; /* 75 characters */}
/* Tighter for mobile */@media (max-width: 640px) { article { max-width: 65ch; }}The ideal range is 45-75 characters for body text. Much shorter, and lines break awkwardly. Much longer, and readers lose their place.
Principle 7: Dark Mode Adjustments
My dark mode looked cramped compared to light mode. The text felt harder to read.
I discovered that light text on dark backgrounds needs more breathing room:
:root { --line-height-base: 1.5;}
@media (prefers-color-scheme: dark) { :root { /* Increase line-height for light text on dark */ --line-height-base: 1.6; }}
body { line-height: var(--line-height-base);}A 0.05-0.1 increase makes dark mode significantly more readable.
Complete Typography Setup
After all these iterations, here’s my final setup:
:root { /* Font families */ --font-sans: 'Plus Jakarta Sans', 'Plus Jakarta Sans Fallback', sans-serif; --font-mono: 'JetBrains Mono', monospace;
/* Modular scale (1.25 ratio) */ --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.25rem; --text-xl: 2rem;
/* Vertical rhythm */ --line-height: 1.5; --baseline: calc(1rem * var(--line-height));
/* Line length */ --line-length: 70ch;}
@media (prefers-color-scheme: dark) { :root { --line-height: 1.6; }}
body { font-family: var(--font-sans); font-size: var(--text-base); line-height: var(--line-height); max-width: var(--line-length);}
/* Headings use the scale */h1 { font-size: var(--text-xl); }h2 { font-size: var(--text-lg); }h3 { font-size: var(--text-base); }
/* Vertical rhythm for spacing */h1, h2, h3, p { margin-bottom: var(--baseline);}
h2, h3 { margin-top: calc(var(--baseline) * 2);}Common Mistakes to Avoid
I made most of these mistakes myself:
-
Too many font families - Stick to 2-3 maximum. One for headings, one for body, one for code if needed.
-
Skipping fallback definitions - Causes layout shift and FOUT (Flash of Unstyled Text).
-
Decorative fonts for body text - Looks terrible and hurts readability. Save display fonts for large headings only.
-
Disabling zoom -
user-scalable=noin viewport meta is an accessibility violation. Never do this. -
Random font sizes - Use a modular scale. If you need a size between two existing sizes, you probably need to reconsider your hierarchy.
Key Takeaways
Good typography is about constraints, not options:
- Use 5 sizes instead of 12
- Pick distinctive fonts instead of defaults
- Match fallback metrics to prevent layout shift
- Use unitless line-height for accessibility
- Base all vertical spacing on line-height
- Control line length with
chunits - Increase line-height for dark mode
The difference between mediocre and great typography isn’t more fonts or more sizes. It’s about making deliberate, constrained choices that create visual hierarchy and improve readability.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments