
How to Pair Fonts: Rules for Combining Two Typefaces
- Sajjad
- Typography
- 01 Oct, 2026
You've chosen a body font you like, and now you need something for headings. You scroll through Google Fonts, try a dozen candidates, and each one looks fine on its own specimen page. But on your actual homepage, the headline font either looks so similar to the body text that it seems like a mistake, or so different that the page feels like two websites stitched together. Font pairing isn't about finding two good fonts. It's about finding two fonts that do different jobs and still look like they belong to the same design.
This article gives you a set of practical rules for combining two typefaces: how to create contrast without conflict, which structural features to match, how to measure and equalize x-heights, and how to test a pairing with real content. It finishes with a table of proven pairs and the code to implement a pairing in CSS, Next.js, and Tailwind CSS v4.
What Font Pairing Is Trying to Achieve
A font pairing assigns two typefaces to different roles, usually:
- Display or heading font: sets headlines, section titles, and sometimes pull quotes. Seen in short bursts, often at large sizes.
- Text or body font: sets paragraphs, lists, captions, and UI text. Read for long stretches at small sizes.
A good pairing makes those roles obvious at a glance, which supports typographic hierarchy, while giving the page a single coherent voice. If you aren't sure whether you need two typefaces at all, read how many fonts a website should use first. One well-chosen family with several weights is often the better answer.
Rule 1: Contrast, Not Conflict
The two fonts need to differ enough that the difference looks intentional. Two similar geometric sans-serifs, like Montserrat and Poppins, look like an accident: readers notice something is slightly off but can't say what.
Good sources of contrast:
| Contrast type | Example |
|---|---|
| Classification | Serif headings with sans-serif body text |
| Weight | Heavy display headings, regular body |
| Width | Condensed headlines, normal-width body |
| Stroke contrast | High-contrast Didone headings, low-contrast sans body |
| Personality | Expressive display face, neutral text face |
Pick one or two dimensions of contrast and keep the rest similar. If the fonts differ in classification, weight, width, and personality all at once, you get conflict instead of contrast. The most reliable starting point is the classic serif and sans-serif pairing, explained in more depth in serif vs. sans-serif fonts.
Rule 2: Match the Underlying Structure
While the surfaces differ, the skeletons should agree. Look at:
- X-height: the height of lowercase letters relative to capitals. Fonts with similar x-height ratios look the same size when set at the same
font-size. - Proportions: are the letters wide and round, or narrow and upright? A wide geometric sans pairs better with a wide, round serif than with a narrow, angular one.
- Construction: humanist fonts (based on pen strokes, like Source Sans 3 or Lora) share a calligraphic logic. Geometric fonts (based on circles and straight lines, like Jost, Futura, or Poppins) share a constructed logic. Matching the construction is one of the most reliable ways to make two classifications feel related.
- Aperture: the openness of letters like c, e, and a. Open apertures in one font and tightly closed ones in the other can look inconsistent.
A quick test: set "Hamburgefonstiv" (a classic type-testing word) in both fonts at the same size and look at the lowercase. If the a, e, g, and o feel like they were drawn by people with similar ideas, the structure is compatible.
Rule 3: Measure and Equalize X-Heights
You don't need to guess at x-height. Fonts store it in the OS/2 table, and you can read it with fontTools:
pip install fonttools brotli
python3 - <<'EOF'
from fontTools.ttLib import TTFont
for path in ["SourceSerif4-Regular.ttf", "SourceSans3-Regular.ttf"]:
f = TTFont(path)
upm = f["head"].unitsPerEm
xh = f["OS/2"].sxHeight
cap = f["OS/2"].sCapHeight
print(f"{path}: x-height {xh / upm:.3f} em, cap height {cap / upm:.3f} em")
EOF
If the ratios differ by more than about 0.03 em, the fonts will look mismatched in size, especially when they appear in the same line, such as a sans-serif label next to serif text.
CSS can correct this. font-size-adjust scales a font so its x-height equals a specified fraction of the font-size, and it's supported in all modern browsers:
:root {
--font-heading: "Fraunces", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
}
body {
font-family: var(--font-body);
}
h1,
h2,
h3 {
font-family: var(--font-heading);
}
/* Inline serif inside sans text, normalized to the same x-height */
.serif-inline {
font-family: var(--font-heading);
font-size-adjust: ex-height 0.53;
}
Use font-size-adjust mostly where both fonts share a line. For headings that sit on their own lines, a small font-size adjustment in your type scale is simpler.
Rule 4: Give Each Font One Clear Job
Assign roles and stick to them. A common failure is letting the display font creep into places it wasn't designed for: buttons, form labels, navigation, or body text.
A simple role map:
| Element | Font |
|---|---|
| H1–H3 | Heading font |
| H4–H6 | Body font, bold or semibold |
| Body, lists, tables | Body font |
| Buttons, navigation, forms | Body font |
| Pull quotes | Heading font, italic if available |
| Captions, metadata | Body font, smaller size |
Using the body font for smaller headings is a useful trick. Display fonts often lose their character and legibility at small sizes, and switching to the body font at H4 keeps minor headings readable.
Rule 5: Use Superfamilies When in Doubt
A superfamily is a set of families designed together, such as a sans, a serif, and sometimes a mono, all sharing proportions and metrics. They're the safest pairing because the structural matching has already been done.
Well-known free options:
- IBM Plex: Plex Sans, Plex Serif, Plex Mono, plus condensed and non-Latin versions.
- Source: Source Sans 3, Source Serif 4, Source Code Pro.
- Noto: Noto Sans and Noto Serif, with coverage for most of the world's scripts.
- DM: DM Sans, DM Serif Display, DM Serif Text, DM Mono.
- Roboto: Roboto, Roboto Serif, Roboto Mono, Roboto Slab.
The trade-off is that superfamily pairings can feel safe rather than distinctive. That's often exactly right for product and documentation sites.
Rule 6: Respect Historical and Stylistic Context
Fonts carry associations from the era and context they come from. A 1950s grotesque, a Renaissance-style serif, and a 2020s variable display face each bring a flavor. Pairing fonts from compatible contexts usually works better than mixing eras randomly.
That doesn't mean they must come from the same period. Plenty of excellent pairings deliberately combine a classical serif with a modern sans. But the combination should feel like a choice. If you can't explain why the two belong together in a sentence, the pairing probably doesn't work.
Rule 7: Test with Real Content
Specimen pages show fonts at their best. Your site will show them with your content, your colors, and your layout. Before committing:
- Use a real page, not lorem ipsum. Include a long headline that wraps, a short one, body paragraphs, a list, a link, a button, and a table.
- Test at your actual sizes. Pairings that work at 72px and 18px can fall apart at 32px and 15px.
- Check on Windows and a low-density screen, not only a Retina Mac. Thin strokes and fine serifs behave differently.
- Squint test. Blur your eyes or apply a blur filter. The headings should still clearly read as headings.
- Check weights and italics. Make sure both families have the styles you need, such as an italic for the body font. A missing italic forces the browser to fake one.
- Check performance. Two families with several weights each add up. Count the files in DevTools.
Proven Google Fonts Pairings
These pairs are all available on Google Fonts and follow the rules above. Treat them as starting points to test, not guaranteed answers.
| Headings | Body | Why it works | Good for |
|---|---|---|---|
| Fraunces | Inter | Soft, warm serif against a neutral, highly legible sans | SaaS, blogs, product sites |
| Playfair Display | Source Sans 3 | High-contrast display serif with a humanist sans | Editorial, lifestyle |
| DM Serif Display | DM Sans | Same superfamily, shared geometric proportions | Startups, portfolios |
| IBM Plex Sans | IBM Plex Serif | Superfamily; sans headings with a serif for reading | Documentation, long reads |
| Space Grotesk | Source Serif 4 | Quirky grotesque headings with a sober text serif | Tech blogs, agencies |
| Libre Baskerville | Source Sans 3 | Classical serif warmth with a clean modern body | Professional services |
If you prefer the body text in a serif, flip the roles: a sturdy serif like Source Serif 4 or Literata for paragraphs, with a sans like Inter for headings and UI. For more single-font candidates, see the guides to the best Google Fonts for body text and headlines.
Implementing and Reviewing a Pairing
Plain CSS with Custom Properties
Keep the font stacks in custom properties so swapping a pairing is a two-line change:
:root {
--font-heading: "Playfair Display", Georgia, "Times New Roman", serif;
--font-body: "Source Sans 3", system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
font-family: var(--font-body);
line-height: 1.6;
}
h1,
h2,
h3,
blockquote {
font-family: var(--font-heading);
line-height: 1.2;
}
Choose fallbacks with a similar classification and width, so the page doesn't collapse into a mismatched layout if a web font fails.
Next.js with next/font
Load both families once and expose them as CSS variables:
// app/fonts.ts
import { Fraunces, Inter } from "next/font/google";
export const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
});
export const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
// app/layout.tsx
import { fraunces, inter } from "./fonts";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${fraunces.variable} ${inter.variable}`}>
<body>{children}</body>
</html>
);
}
next/font also generates a size-adjusted fallback font for each family, which reduces layout shift when the web fonts load.
Tailwind CSS v4
Map the variables to theme tokens so you get font-heading and font-body utilities:
/* app/globals.css */
@import "tailwindcss";
@theme inline {
--font-heading: var(--font-fraunces), Georgia, serif;
--font-body: var(--font-inter), system-ui, sans-serif;
}
@layer base {
body {
font-family: var(--font-body);
}
h1,
h2,
h3 {
font-family: var(--font-heading);
}
}
@theme inline makes the utilities reference the next/font variables directly, so the values resolve on the element where the font classes are applied.
Common Pairing Mistakes
- Two display fonts. Two expressive faces compete for attention. One should always be quiet.
- Near-identical fonts. Two geometric sans-serifs or two transitional serifs look like an error.
- Mismatched moods. A playful rounded display font on a serious financial site, or a stiff corporate sans for a children's brand.
- Ignoring weights. A thin display font over a heavy body font inverts the hierarchy.
- Different x-heights in the same line. Labels and inline code in a second font look too big or too small without adjustment.
Font Pairing FAQ
No, but it is the most reliable starting point because the contrast is obvious. Two sans-serifs can work if they differ clearly in width, weight, or personality, and a superfamily can pair a sans and serif that already share proportions.
Two families is the practical maximum for most sites, with a monospace font as an optional third for code. Many excellent sites use a single family and create hierarchy with weight and size alone.
Yes. What matters is structural compatibility, not who made the fonts. Check x-height, proportions, and construction, and test both fonts together on a real page rather than relying on specimens.
A superfamily such as IBM Plex, Source, or Noto is the safest choice, because the sans and serif were designed with matching metrics. They may feel less distinctive, but they almost never look wrong.
Compare their x-heights, which you can read from each font's OS/2 table. If they differ, adjust font sizes in your type scale, or use the CSS font-size-adjust property where both fonts appear in the same line.
It adds requests and bytes, so keep the number of weights low. Two families with two or three styles each, subset to the characters you need and served as WOFF2, usually add a manageable amount, especially with next/font or self-hosting.
Conclusion
Pairing fonts well comes down to a few principles: create contrast on one or two clear dimensions, match the underlying structure, keep x-heights compatible, and give each font a distinct job. Superfamilies are a safe shortcut, and historical or stylistic context helps you choose combinations that feel deliberate.
Then test with real content at real sizes on real screens, and implement the pairing through custom properties or theme tokens so it's easy to adjust later. A pairing that survives a real page, a Windows laptop, and a squint test will serve you better than one that only looked good on a specimen.
Here are some useful references for going deeper on font pairing:
- Google Fonts Knowledge: Google Fonts Knowledge — lessons on choosing and combining typefaces.
- Butterick's Practical Typography: Font recommendations — opinionated guidance on choosing and mixing fonts.
- MDN Web Docs: font-size-adjust — normalizing x-heights between fonts.
- Next.js Docs: Font Optimization — loading multiple fonts with
next/font. - Tailwind CSS Docs: font-family — defining custom font utilities with theme variables.


