Typography basics

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: The handful of typography decisions — type scale, line height, font choice, weight contrast — that make text feel professional, readable, and trustworthy without needing to study design for a year.


In plain English

Most webapps live or die on text. People READ on the screen — headings, body, buttons, labels, error messages. If the text is hard to read, the app feels hard. If the text feels coherent and confident, the app feels coherent and confident, even when the underlying functionality is plain.

Typography is the discipline of arranging text. For an engineer building a webapp without formal design training, you don’t need to study it deeply — but a handful of decisions move you from “amateur” to “competent” with surprisingly little effort:

  1. Pick one or two fonts. Three is too many. Stick.
  2. Use a type scale — a small, fixed set of font sizes for the entire app.
  3. Set line height carefully. Body text: 1.5. Headings: 1.1-1.25.
  4. Use weight contrast instead of color or italic for emphasis.
  5. Cap line length at 60-80 characters for body text.
  6. Set base size to 16 px. Don’t go smaller for body.
  7. Add letter-spacing to small uppercase labels; don’t touch body text spacing.

Apply those seven, and your app’s text will look more polished than 80% of solo-developer projects. Add a touch of restraint, and you’ve covered most of what typography offers a non-designer.

The 2026 stack makes this easier: Tailwind gives you a thoughtful default type scale; Google Fonts and the next/font system make typography free and fast; shadcn/ui ships with reasonable typography baked in. You’re often a few tweaks away from “looks designed.”


Why it matters

Three concrete reasons typography is high-leverage:

  1. Most of your app IS text. Buttons, labels, headings, paragraphs, errors, tooltips — text dominates the interface. Tuning text affects every screen.

  2. Polish compounds. Most products feel “engineer-built” because the typography is whatever the framework defaulted to. Spending 30 minutes choosing a font + type scale moves the perceived quality bar dramatically.

  3. Readability is accessibility. Type that’s too small, too tight, or poorly contrasted excludes users with low vision or dyslexia. Good typography is also good a11y.

The trade-off: typography rewards restraint. The temptation is to use 5 fonts, 12 sizes, italic + bold + colored variants everywhere. The professional move is the opposite — fewer variations, more consistency.


The seven decisions (in detail)

1. Pick one or two fonts

For 95% of webapps: one font for everything is plenty. A well-chosen sans-serif (Inter, Geist, system-ui) handles body, headings, code labels, buttons.

If you want contrast between body and headings, the safe move is one sans-serif + one serif (e.g. Inter for body + a humanist serif for headings). Stop there.

Three fonts is almost always a mistake. The screen feels noisy; nothing feels like the “primary.”

Good defaults in 2026:

Use caseSuggested font
Default sans-serifInter (free, comprehensive, neutral)
Modern technical lookGeist (Vercel’s, ships with Next.js)
System default (zero cost, native)system-ui / -apple-system, BlinkMacSystemFont, ...
Serif for headingsSource Serif Pro, Crimson Pro, Libre Caslon
Display / brandCal Sans, Playfair Display, Fraunces
Monospace (code)JetBrains Mono, Geist Mono, Fira Code

For Bible Quest-style projects, Inter + a single serif for verse text (if you want gravitas on scripture) is a strong starting point. Or just Inter everywhere.

2. Use a type scale

A type scale is the small, fixed set of font sizes you’ll use across the app. Instead of “this label is 13 px, this one is 14 px, this one is 12 px,” you pick maybe 6-8 sizes and stick to them.

A typical type scale:

TokenSizeUsage
text-xs12 pxCaptions, fine print, badges
text-sm14 pxSecondary text, small buttons, dense lists
text-base16 pxBody text (default)
text-lg18 pxSlightly emphasized body, large links
text-xl20 pxCard titles, small headings
text-2xl24 pxSection headings
text-3xl30 pxPage subheadings
text-4xl36 pxPage titles
text-5xl+48 px+Hero headlines, landing page bombast

This is exactly Tailwind’s default scale. You get it for free.

Most of your app uses text-sm (UI chrome) and text-base (body). Heading sizes appear less often. Avoid ad-hoc sizes (text-[15px]) — they break the rhythm.

A modular scale (like 1.250 — major third) generates sizes mathematically. Tailwind’s defaults approximate this. You don’t need to compute your own.

3. Line height matters more than you think

Line height (CSS line-height) is the vertical space between lines of text. Too tight = cramped. Too loose = paragraphs feel disconnected.

ContentLine height
Body text (paragraphs)1.5 to 1.65 (Tailwind: leading-relaxed or leading-loose)
Compact UI text (buttons, labels)1.2 to 1.4 (Tailwind: leading-snug or leading-tight)
Headings1.1 to 1.25 (Tailwind: leading-tight)

The single biggest improvement to a “developer-default” looking page: bump body text from 1.0 (which most browsers default to) to 1.5. The text becomes immediately easier to read.

Tailwind’s prose class (from @tailwindcss/typography) handles this and many other body-text concerns. For long-form content (Bible passages, blog posts, documentation), <article className="prose"> is a one-line win.

4. Use weight, not color, for emphasis

Two ways to make text stand out: change its weight (light, regular, medium, semibold, bold) or its color.

Weight contrast is the professional move:

  • Body text in font-normal (400)
  • A key term within: font-semibold (600) or font-medium (500)
  • Headings: font-semibold or font-bold (700)

Color contrast for emphasis usually looks amateur:

  • Body in black, emphasized words in red? Looks like a 1998 GeoCities page
  • Italic? Subtle but easily lost on screen
  • Underline? Confuses with links

The exception: muted text for de-emphasis (text-muted-foreground in shadcn — usually a 40-60% gray). De-emphasizing by color works; emphasizing by color usually doesn’t.

5. Cap line length

Reading line lengths over ~80 characters tires the eye. Lines under ~45 characters break too often. The sweet spot for body text: 60-80 characters per line (about 600-800 px at 16 px font).

Set max-width on body content:

.prose {
  max-width: 65ch;        /* 65 characters wide */
}

ch is a CSS unit equal to the width of the “0” character — line-length-aware sizing for free.

For Tailwind: max-w-prose gives you 65ch. Use it on article content, long form, anywhere users read more than a single line.

6. Base size 16 px (or larger)

The standard web base size for body text is 16 px. Smaller than that is hard to read on phones, requires zooming for low-vision users, and feels cramped.

If you must go smaller for UI chrome (captions, footnotes), 14 px is the floor. Below 12 px is uncomfortable.

Mobile-specifically: some sites bump base to 17 px or 18 px for body, recognizing that phone viewing distances are different from desktop.

7. Letter spacing — tighten headings, leave body alone

Letter spacing (CSS letter-spacing) is the space between characters. Small adjustments make a difference:

  • Body text — leave at default (0). Adjusting it harms readability.
  • Large headings — slightly tighter often looks better (tracking-tight in Tailwind, ≈ -0.025em).
  • Small uppercase labels — slightly looser improves readability (tracking-wide to tracking-wider, ≈ +0.025em to +0.05em).
<h1 className="text-4xl font-semibold tracking-tight">Welcome</h1>
<p className="text-base leading-relaxed">A normal paragraph.</p>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
  Category
</span>

A concrete example: a polished homepage hero

Compare a “default” engineer hero:

<div>
  <h1>Bible Quest</h1>
  <p>A modern way to study scripture daily.</p>
  <button>Get started</button>
</div>

With typography decisions applied:

<div className="max-w-prose mx-auto py-24 text-center">
  <h1 className="text-5xl font-semibold tracking-tight text-foreground">
    Bible Quest
  </h1>
  <p className="mt-6 text-lg leading-relaxed text-muted-foreground">
    A modern way to study scripture daily.
  </p>
  <button className="mt-8 px-6 py-3 text-base font-medium rounded-md bg-primary text-primary-foreground">
    Get started
  </button>
</div>

What changed:

  • Single font (the default sans-serif loaded via next/font)
  • Heading uses text-5xl (48 px) — big enough to be a hero, not absurd
  • font-semibold not font-bold — more refined
  • tracking-tight on the heading — large text wants tighter spacing
  • Body in text-lg not text-base — slightly larger for hero context
  • leading-relaxed on body — comfortable reading
  • text-muted-foreground on the body — de-emphasizes vs the heading
  • mt-6, mt-8 for spacing — rhythmic, not arbitrary
  • max-w-prose — keeps the line length sane

No new tools, no new colors. Just typography decisions. Hours of design work compressed into about 7 lines.


Loading custom fonts in Next.js

next/font (since Next 13) makes custom fonts trivially fast and CLS-free:

// app/layout.tsx
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
});
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

tailwind.config.ts:

export default {
  theme: {
    fontFamily: {
      sans: ["var(--font-inter)", "system-ui", "sans-serif"],
    },
  },
};

What this does:

  • Downloads Inter at build time (no runtime network roundtrip)
  • Self-hosts it (Vercel serves it; no external request to Google Fonts at page load)
  • Applies a CSS variable + font-family fallback
  • Eliminates the FOUT / FOIT (flash of unstyled / invisible text)

For a second font (a serif for headings):

import { Inter, Crimson_Pro } from "next/font/google";
 
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const crimson = Crimson_Pro({ subsets: ["latin"], variable: "--font-serif" });
 
// Apply both class names to <html>

Then in Tailwind, define font-serif: ["var(--font-serif)", "serif"] and use font-serif on the elements you want.


Variable fonts — a quiet 2020s win

Most modern web fonts are now variable fonts — a single file contains many weights, widths, and styles. Instead of loading 4 files (Regular, Bold, Italic, Bold Italic), you load one and request weights as needed.

Practical benefits:

  • Smaller total payload (one file vs many)
  • Smoother weight transitions
  • More design flexibility (any weight from 100 to 900, not just preset stops)

Inter, Geist, Source Sans, Crimson Pro are all variable. next/font handles them automatically; you just use font-weight: 500 and the right weight is selected.


Common gotchas

  • Three fonts looks worse than one. Adding fonts feels like adding personality; usually it just adds noise. Restraint wins.

  • Default browser line-height (~1.2) is too tight for body text. Always set leading-relaxed (1.625) or similar on paragraphs. The improvement is dramatic.

  • Ad-hoc sizes shatter the type scale. Don’t reach for text-[15px] because “14 is too small but 16 is too big.” Either pick from the scale or extend the scale once for the whole app.

  • Justified text breaks readability. text-align: justify looks crisp in a typewritten book and broken on a small screen with weird word spacing. Leave it left-aligned (or center for short headings).

  • Italic doesn’t render well at small sizes. A 12-px italic word in body text looks blurry. Use bold for emphasis at small sizes.

  • All-caps body text is hard to read. Word shapes disappear; brain has to spell out each word. Reserve all-caps for SHORT labels (3-15 characters).

  • Line height in em vs unitless. line-height: 1.5 (unitless) scales correctly with font-size. line-height: 1.5em doesn’t compose well in nested elements. Use unitless.

  • Letter-spacing on body text degrades readability. Anything beyond default is too much for paragraphs. Reserve for short emphasis.

  • Centered body text is uncomfortable. Each line starts at a different x-position; the eye has to find the start of every line. Center HEADINGS, not paragraphs.

  • Hairline weights (100, 200) at small sizes are unreadable. A 12-px font-weight-200 is anemic. Stick to 400+ for body; 500+ for UI.

  • Auto-hyphenation can help long words on narrow columns. hyphens: auto (with lang attribute set) lets the browser break long words at hyphens. Especially useful on phones.

  • text-overflow: ellipsis needs white-space: nowrap and overflow: hidden. Forgetting any of the three breaks the ellipsis. Tailwind’s truncate utility does all three.

  • Tabular numbers matter for tables. font-variant-numeric: tabular-nums makes all digits the same width, so numbers in a column align. Especially useful for financial / data tables.

  • font-display: swap reduces invisibility. Tells the browser to use a fallback font immediately, then swap to the custom one when loaded. next/font handles this; if you’re loading fonts manually, set it.

  • Some custom fonts ship without certain weights/styles. Variable fonts solve this; static-weight fonts may have only Regular + Bold. Verify before designing.

  • text-rendering: optimizeLegibility was a 2010s thing. Modern browsers handle this automatically; setting it can actually slow rendering. Don’t bother.

  • Don’t use <center> or <font> tags. They’ve been deprecated for two decades. Use CSS.

  • Don’t disable smooth font rendering. -webkit-font-smoothing: antialiased is a holdover; modern browsers render fonts fine without it. Some Mac-only ports of fonts look thinner with antialiased; test before applying.

  • Don’t use Lorem Ipsum past wireframing. Real content reveals real problems with type sizes, line lengths, and copy length. Use actual sample data early.

  • prose class behaviors can collide. Tailwind’s @tailwindcss/typography plugin styles all child elements (headings, links, lists). If you customize, be aware of the cascade.

  • Tracking-tight on small text is illegible. It’s a heading-only optimization. Body text at default tracking, please.

  • Mobile users read at greater distance than designers. Type sizes that look big on a 27” monitor at 24 inches away look small on a phone at 18 inches away. Test on real phones.

  • Font fallbacks matter when custom fonts fail. A weight-700 word in your custom font looks fine; the fallback might be lighter, causing layout shift. Set size-adjust, ascent-override in @font-face to match metrics. next/font does this automatically with the adjustFontFallback option.

  • em vs rem choice matters. rem is relative to the root font size; em is relative to the parent. For consistent scaling, prefer rem. Use em for component-relative spacing (e.g., margin around a heading that scales with the heading).

  • Don’t use text-align: justify on mobile. Already a gamble on desktop; on narrow screens, the word spacing becomes grotesque.

  • Punctuation and quotes matter. Use smart quotes (” ” ’ ’) not straight (” ”). Em dashes (—) and en dashes (–) for ranges. Most type-aware text editors do this automatically; Markdown often doesn’t.

  • font-feature-settings opens advanced features. Ligatures, stylistic sets, slashed zeros. Most variable fonts ship with thoughtful defaults; you rarely need to touch these. When you do, the syntax is font-feature-settings: "salt" on; and similar.

  • Dark mode and typography interact. Heavy text weights look heavier on dark backgrounds (light-on-dark optically thickens). Consider a 50-unit lighter weight for body on dark mode (500 → 400 sometimes).

  • AI-generated copy is often the wrong length. Designs that look balanced with AI placeholder text may break with real, longer (or shorter) human writing. Test with realistic content length.


Tools and resources


See also


Sources