Responsive Typography

โ–ถ Try It Yourself

Typography that works at 320px and at 1440px requires a deliberate system โ€” not a pile of media queries. Modern CSS gives us fluid type sizing with clamp(), container queries, and custom property cascades that eliminate most manual breakpoints for text. In this lesson you will build a complete responsive typography system using fluid type scales, viewport-relative units, and the techniques used by professional design systems at scale.

Responsive Typography Strategies

Approach How Pros Cons
Media query breakpoints Different font-size at each breakpoint Precise control Verbose; sudden jumps between sizes
Viewport units (vw) font-size: 4vw Perfectly fluid Too small on mobile, too large on desktop without limits
clamp(min, preferred, max) Fluid between hard limits No media queries; smooth scaling; accessible Slightly complex syntax
CSS custom property scale Define scale in :root; override at breakpoints Centralised; token-based Requires planning upfront
Container queries @container + cqi unit Component-relative โ€” not viewport-relative Requires container context

clamp() Fluid Type Formula

Parameter Meaning Example
Minimum Smallest the text will ever be (mobile) 1rem (16px)
Preferred (fluid) Viewport-relative value that scales smoothly 2.5vw
Maximum Largest the text will ever be (desktop) 1.5rem (24px)
Result clamp(1rem, 2.5vw, 1.5rem) 16px โ†’ 24px between ~640px and ~960px viewport

Modular Type Scale Values

Level Mobile (min) Fluid preferred Desktop (max)
Display / h1 2rem (32px) 5vw 4rem (64px)
Heading / h2 1.5rem (24px) 3.5vw 2.5rem (40px)
Subheading / h3 1.25rem (20px) 2.5vw 1.75rem (28px)
Body 1rem (16px) 1.2vw 1.125rem (18px)
Small / caption 0.875rem (14px) โ€” 0.875rem (14px)
Note: The middle value of clamp() should use vw or a calc expression like calc(1rem + 1.5vw). Using calc(1rem + 1.5vw) as the preferred value ensures the scaling starts from a base of 1rem and grows linearly with viewport width โ€” this is often smoother than a raw vw value which can be 0 at 0px viewport.
Tip: A powerful shorthand for fluid type: clamp(1rem, calc(1rem + 1vw), 1.5rem). The calc(1rem + 1vw) middle means “start at 16px and grow 1% of viewport width per 100px”. At 400px viewport: 16 + 4 = 20px (clamped to 16px min). At 1000px: 16 + 10 = 26px. At 1600px: still 24px (clamped to max).
Warning: Avoid using font-size: Nvw without clamp(). At a 320px viewport, 4vw is 12.8px โ€” below the 16px minimum recommended for body text. At a 1920px monitor, 4vw is 76.8px โ€” far too large. Always wrap viewport-relative font sizes inside clamp() to enforce sensible limits.

Basic Example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
  <title>Responsive Typography</title>
  <style>
    html { font-size: 100%; }
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    /* โ”€โ”€ Fluid type scale via custom properties โ”€โ”€ */
    :root {
      --text-sm:      clamp(0.875rem, calc(0.875rem + 0.1vw),  0.9375rem);
      --text-base:    clamp(1rem,     calc(1rem + 0.15vw),     1.125rem);
      --text-lg:      clamp(1.125rem, calc(1rem + 0.5vw),      1.375rem);
      --text-xl:      clamp(1.25rem,  calc(1rem + 1vw),        1.75rem);
      --text-2xl:     clamp(1.5rem,   calc(1rem + 1.75vw),     2.25rem);
      --text-3xl:     clamp(1.875rem, calc(1rem + 3vw),        3rem);
      --text-display: clamp(2.5rem,   calc(1.5rem + 5vw),      5rem);

      --font-sans: 'Inter', system-ui, sans-serif;
      --line-tight:   1.15;
      --line-body:    1.7;
      --color-ink:    #0f172a;
      --color-muted:  #64748b;
      --color-accent: #4f46e5;
    }

    body {
      font-family: var(--font-sans);
      font-size:   var(--text-base);
      line-height: var(--line-body);
      color:       var(--color-ink);
      background:  #f8fafc;
    }

    /* โ”€โ”€ Page sections โ”€โ”€ */
    .page { max-width: 1100px; margin: 0 auto; padding: 48px 24px; }

    /* โ”€โ”€ Hero โ”€โ”€ */
    .hero {
      text-align: center;
      padding: 80px 24px;
      background: linear-gradient(135deg, #1e293b, #312e81);
      border-radius: 20px;
      margin-bottom: 64px;
    }
    .hero-label {
      font-size: var(--text-sm);
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.1em;
      color: #a5b4fc;
      margin-bottom: 16px;
    }
    .hero-title {
      font-size: var(--text-display);
      font-weight: 800;
      line-height: var(--line-tight);
      letter-spacing: -0.03em;
      color: white;
      margin-bottom: 24px;
      max-width: 18ch;
      margin-inline: auto;
    }
    .hero-sub {
      font-size: var(--text-lg);
      line-height: 1.6;
      color: #94a3b8;
      max-width: 55ch;
      margin-inline: auto;
    }

    /* โ”€โ”€ Prose section โ”€โ”€ */
    .prose { max-width: 65ch; margin: 0 auto 64px; }
    .prose h2 { font-size: var(--text-2xl); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; margin-bottom: 16px; }
    .prose h3 { font-size: var(--text-xl); font-weight: 600; line-height: 1.3; letter-spacing: -0.01em; margin: 32px 0 12px; }
    .prose p  { font-size: var(--text-base); line-height: 1.75; color: #475569; margin-bottom: 16px; }

    /* โ”€โ”€ Fluid grid of type samples โ”€โ”€ */
    .type-samples { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
    .type-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }
    .type-card .meta { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-muted); margin-bottom: 8px; }
    .type-card .sample { color: var(--color-ink); font-weight: 700; line-height: 1.2; }
  </style>
</head>
<body>
  <div class="page">

    <section class="hero">
      <p class="hero-label">Fluid Typography System</p>
      <h1 class="hero-title">Type That Scales with Your Layout</h1>
      <p class="hero-sub">Every size in this page is defined with clamp() โ€” fluid between a minimum on mobile and a maximum on desktop, with no media queries for font sizes.</p>
    </section>

    <div class="type-samples">
      <div class="type-card"><p class="meta">--text-display</p><p class="sample" style="font-size:var(--text-display)">Aa</p></div>
      <div class="type-card"><p class="meta">--text-3xl</p><p class="sample" style="font-size:var(--text-3xl)">Aa</p></div>
      <div class="type-card"><p class="meta">--text-2xl</p><p class="sample" style="font-size:var(--text-2xl)">Aa</p></div>
      <div class="type-card"><p class="meta">--text-xl</p><p class="sample" style="font-size:var(--text-xl)">Aa</p></div>
      <div class="type-card"><p class="meta">--text-lg</p><p class="sample" style="font-size:var(--text-lg)">Aa</p></div>
      <div class="type-card"><p class="meta">--text-base</p><p class="sample" style="font-size:var(--text-base)">Aa</p></div>
    </div>

    <section class="prose" style="margin-top:48px">
      <h2>Why Fluid Type Scales Work</h2>
      <p>A fluid type scale defines each size as a range โ€” a minimum on narrow viewports and a maximum on wide ones, with smooth linear interpolation between. The result is typography that always feels right, at any viewport width, without a single font-size media query.</p>
      <h3>The clamp() Function</h3>
      <p>The three arguments of clamp are minimum, preferred, and maximum. The browser uses the preferred value unless it would fall outside the min/max bounds โ€” at which point it clamps to the limit. Using calc(1rem + 1.5vw) as the preferred creates a smooth ramp that starts at the base rem size and grows proportionally.</p>
    </section>

  </div>
</body>
</html>

How It Works

Step 1 โ€” Custom Properties Define the Entire Scale

All seven type sizes are defined in :root as custom properties with clamp() values. Any component that needs a size uses font-size: var(--text-lg). To adjust the whole system, you change one declaration โ€” the property in :root.

Step 2 โ€” calc() Inside clamp() Creates a Linear Ramp

calc(1rem + 1.5vw) means “start at 16px and grow 1.5% of viewport width”. At 400px viewport: 16 + 6 = 22px. At 800px: 16 + 12 = 28px. The clamp() wrapping ensures this value never drops below the minimum or exceeds the maximum.

Step 3 โ€” max-width: 18ch on the Hero Title

Even at a 2rem display size, the title wraps at approximately 18 characters โ€” about 3โ€“4 words per line. This creates the classic “stacked headline” look from editorial design. The ch unit auto-adjusts as the font scales with the viewport.

Step 4 โ€” No Font-Size Media Queries Needed

Resize the window to any width โ€” all text sizes adjust smoothly. A 320px mobile gets minimum sizes that are readable; a 1600px widescreen gets the maximum sizes. No breakpoints, no duplicate declarations, no sudden jumps between sizes.

Step 5 โ€” letter-spacing Scales with Font Size via em

Using letter-spacing: -0.03em on the hero title means the tracking is always 3% of the current computed font-size. As the title scales from 40px to 80px via clamp, the letter-spacing automatically scales from -1.2px to -2.4px โ€” maintaining the correct visual tightness at every size.

Real-World Example: Design Token Type System

/* tokens-type.css โ€” production design system type tokens */

:root {
  /* โ”€โ”€ Font families โ”€โ”€ */
  --font-display: 'Playfair Display', Georgia, serif;
  --font-body:    'Inter', system-ui, -apple-system, sans-serif;
  --font-mono:    'JetBrains Mono', 'Fira Code', 'Courier New', monospace;

  /* โ”€โ”€ Fluid type scale โ”€โ”€ */
  --text-2xs:     0.625rem;
  --text-xs:      0.75rem;
  --text-sm:      0.875rem;
  --text-md:      1rem;
  --text-lg:      clamp(1.125rem, calc(1rem + 0.5vw),  1.25rem);
  --text-xl:      clamp(1.25rem,  calc(1rem + 1vw),    1.5rem);
  --text-2xl:     clamp(1.5rem,   calc(1rem + 2vw),    2rem);
  --text-3xl:     clamp(1.875rem, calc(1rem + 3vw),    2.625rem);
  --text-4xl:     clamp(2.25rem,  calc(1rem + 4.5vw),  3.5rem);
  --text-5xl:     clamp(3rem,     calc(1.5rem + 6vw),  5rem);

  /* โ”€โ”€ Font weights โ”€โ”€ */
  --weight-regular:   400;
  --weight-medium:    500;
  --weight-semibold:  600;
  --weight-bold:      700;
  --weight-extrabold: 800;

  /* โ”€โ”€ Line heights โ”€โ”€ */
  --leading-none:     1;
  --leading-tight:    1.15;
  --leading-snug:     1.3;
  --leading-normal:   1.5;
  --leading-relaxed:  1.625;
  --leading-loose:    1.75;

  /* โ”€โ”€ Letter spacing โ”€โ”€ */
  --tracking-tighter: -0.05em;
  --tracking-tight:   -0.025em;
  --tracking-normal:   0em;
  --tracking-wide:     0.025em;
  --tracking-wider:    0.05em;
  --tracking-widest:   0.1em;

  /* โ”€โ”€ Prose measure โ”€โ”€ */
  --measure-narrow: 45ch;
  --measure-default:65ch;
  --measure-wide:   80ch;
}

/* โ”€โ”€ Semantic text styles โ”€โ”€ */
.text-display {
  font-family:    var(--font-display);
  font-size:      var(--text-5xl);
  font-weight:    var(--weight-bold);
  line-height:    var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}
.text-heading-1 {
  font-family:    var(--font-body);
  font-size:      var(--text-4xl);
  font-weight:    var(--weight-extrabold);
  line-height:    var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}
.text-heading-2 {
  font-family:    var(--font-body);
  font-size:      var(--text-3xl);
  font-weight:    var(--weight-bold);
  line-height:    var(--leading-snug);
  letter-spacing: var(--tracking-tight);
}
.text-body {
  font-family:    var(--font-body);
  font-size:      var(--text-md);
  font-weight:    var(--weight-regular);
  line-height:    var(--leading-relaxed);
}
.text-label {
  font-family:    var(--font-body);
  font-size:      var(--text-xs);
  font-weight:    var(--weight-semibold);
  line-height:    var(--leading-normal);
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
}
.text-caption {
  font-family:    var(--font-body);
  font-size:      var(--text-sm);
  line-height:    var(--leading-normal);
  color:          #64748b;
}
.text-code {
  font-family:    var(--font-mono);
  font-size:      0.875em;
  line-height:    var(--leading-relaxed);
}

Common Mistakes

Mistake 1 โ€” Unsupported clamp() usage in older builds

โŒ Wrong โ€” clamp() is not supported in IE11 or early Edge (pre-Chromium):

h1 { font-size: clamp(2rem, 5vw, 4rem); } /* no fallback for legacy browsers */

โœ… Correct โ€” provide a static fallback before the clamp() declaration:

h1 { font-size: 2.5rem; }                  /* fallback */
h1 { font-size: clamp(2rem, 5vw, 4rem); }  /* overrides in supporting browsers */

Mistake 2 โ€” Using raw vw without minimum for body text

โŒ Wrong โ€” at narrow viewports, body text becomes too small to read:

p { font-size: 2vw; } /* at 400px viewport = 8px โ€” unreadable */

โœ… Correct โ€” always clamp viewport-relative sizes:

p { font-size: clamp(1rem, 2vw, 1.25rem); }

Mistake 3 โ€” Building a type system inside each component

โŒ Wrong โ€” each component defines its own font sizes in isolation:

.card-title { font-size: 1.25rem; }
.modal-title { font-size: 1.3rem; }
.sidebar-heading { font-size: 1.2rem; } /* inconsistent โ€” hard to maintain */

โœ… Correct โ€” define sizes once in :root tokens, reference everywhere:

:root { --text-xl: clamp(1.25rem, calc(1rem + 1vw), 1.5rem); }
.card-title, .modal-title, .sidebar-heading { font-size: var(--text-xl); }

▶ Try It Yourself

Quick Reference

Technique CSS Notes
Fluid heading clamp(2rem, 5vw, 4rem) No font-size media queries needed
Fluid body clamp(1rem, calc(1rem + 0.5vw), 1.25rem) Gentle scaling for comfortable reading
Token scale --text-xl: clamp(...) in :root Single source of truth for all sizes
Measure max-width: 65ch Optimal reading line length
Tight heading tracking letter-spacing: -0.02em em scales with font-size
Viewport fallback Static px before clamp() Legacy browser support

🧠 Test Yourself

What does font-size: clamp(1rem, calc(1rem + 2vw), 2rem) produce at a 500px viewport?





โ–ถ Try It Yourself