Fluid Typography ด้วย CSS clamp(): ไม่ต้องใช้ Media Queries
Fluid Typography with CSS clamp(): No Media Queries Needed
For years, responsive typography meant writing font-size declarations inside multiple @media queries — one for mobile, one for tablet, one for desktop. The result was text that jumped between sizes at defined breakpoints rather than flowing smoothly as the viewport expanded.
CSS clamp() changes this entirely. With a single property value, you can define a font size that scales linearly between a minimum and maximum, relative to the viewport width. No breakpoints. No jumps. Just smooth, proportional scaling.
This guide covers the concept in depth: how clamp() works, how to derive the formula, how to build a complete fluid type system, and how to ensure accessibility compliance along the way.
The Problem with Breakpoint-Based Typography
The traditional approach looks something like this:
/* Breakpoint-based — text jumps at each breakpoint */
h1 {
font-size: 2rem; /* 32px on mobile */
}
@media (min-width: 768px) {
h1 {
font-size: 2.75rem; /* 44px on tablet */
}
}
@media (min-width: 1200px) {
h1 {
font-size: 3.5rem; /* 56px on desktop */
}
}
This creates several problems:
-
Abrupt transitions. At 767px the heading is one size; at 768px it's suddenly 37% larger. Users on viewports right at a breakpoint get a jarring experience.
-
Arbitrary boundaries. Breakpoints are defined for device categories, not for optimal reading at every width. A viewport at 900px (which falls in your "tablet" category) might actually have plenty of room for larger text.
-
Verbose CSS. Every typographic element that needs responsive sizing requires multiple declarations across multiple media queries. The codebase grows large and becomes difficult to maintain.
-
Edge cases. What about the infinite range of viewport sizes between breakpoints? You're essentially defining the min and max, but not the gradient between them.
The ideal solution is type that responds continuously to viewport width — scaling smoothly from its minimum size at the narrowest viewports to its maximum size at the widest.
Understanding CSS clamp(min, preferred, max)
CSS clamp() takes three arguments:
clamp(minimum, preferred, maximum)
- minimum — The smallest value the property can be. Applies when the preferred value would be smaller.
- preferred — The value the browser tries to use. Usually a viewport-relative unit like
vwor a calculation involving one. - maximum — The largest value the property can be. Applies when the preferred value would exceed it.
The browser computes: max(minimum, min(preferred, maximum)) — though the clamp() syntax is far more readable.
Some simple examples:
/* Font size that's always between 1rem and 2rem */
.fluid-text {
font-size: clamp(1rem, 2.5vw, 2rem);
}
/* At a viewport of 320px: 2.5vw = 8px → clamped to 1rem (16px) */
/* At a viewport of 800px: 2.5vw = 20px → preferred value used (20px) */
/* At a viewport of 1200px: 2.5vw = 30px → clamped to 2rem (32px) */
The preferred value in the middle is where the fluid behavior lives. As long as the computed preferred value falls between min and max, the viewport controls the font size.
The Fluid Typography Formula
The challenge with clamp() is writing the preferred value so that it:
1. Equals the minimum at your smallest target viewport
2. Equals the maximum at your largest target viewport
3. Scales linearly in between
The math is slope-intercept form — familiar from basic algebra. We want a line that passes through two points:
- Point A:
(viewport_min, size_min) - Point B:
(viewport_max, size_max)
The slope (rate of change per pixel of viewport width):
slope = (size_max - size_min) / (viewport_max - viewport_min)
The y-intercept (offset in rem):
intercept = size_min - slope * viewport_min
Which gives us the preferred value:
preferred = intercept + slope * 100vw
Let's work through a real example. We want an h1 that:
- Is 2rem (32px) at a minimum viewport of 320px
- Is 4rem (64px) at a maximum viewport of 1440px
slope = (64 - 32) / (1440 - 320) = 32 / 1120 = 0.02857
intercept = 32 - 0.02857 * 320 = 32 - 9.143 = 22.857px = 1.4286rem
preferred = 1.4286rem + 2.857vw
In CSS:
h1 {
font-size: clamp(2rem, 1.4286rem + 2.857vw, 4rem);
}
Or rounding to practical precision:
h1 {
font-size: clamp(2rem, 1.43rem + 2.86vw, 4rem);
}
The font is exactly 32px at 320px viewport, exactly 64px at 1440px viewport, and smoothly scales between those points at any viewport in between.
Generating clamp() Values for Your Type Scale
Calculating these values manually every time is tedious. The best approach is either a tool or a Sass/PostCSS function.
Using the Formula in Sass
@function fluid-size($min-size, $max-size, $min-viewport: 320, $max-viewport: 1440) {
$slope: ($max-size - $min-size) / ($max-viewport - $min-viewport);
$intercept: $min-size - $slope * $min-viewport;
// Convert px to rem (assuming 16px base)
$min-rem: #{$min-size / 16}rem;
$max-rem: #{$max-size / 16}rem;
$intercept-rem: #{$intercept / 16}rem;
$slope-vw: #{$slope * 100}vw;
@return clamp(#{$min-rem}, calc(#{$intercept-rem} + #{$slope-vw}), #{$max-rem});
}
// Usage
h1 { font-size: fluid-size(32, 64); }
h2 { font-size: fluid-size(26, 48); }
h3 { font-size: fluid-size(22, 36); }
h4 { font-size: fluid-size(18, 28); }
p { font-size: fluid-size(16, 20); }
Hardcoded Scale for Reference
Here's a complete fluid type scale using pre-calculated clamp() values (min viewport: 320px, max viewport: 1440px):
:root {
/* Fluid type scale */
--text-xs: clamp(0.75rem, 0.71rem + 0.18vw, 0.875rem); /* 12px → 14px */
--text-sm: clamp(0.875rem, 0.82rem + 0.27vw, 1rem); /* 14px → 16px */
--text-base: clamp(1rem, 0.93rem + 0.36vw, 1.25rem); /* 16px → 20px */
--text-lg: clamp(1.125rem, 1.02rem + 0.54vw, 1.5rem); /* 18px → 24px */
--text-xl: clamp(1.25rem, 1.07rem + 0.89vw, 1.875rem); /* 20px → 30px */
--text-2xl: clamp(1.5rem, 1.18rem + 1.61vw, 2.5rem); /* 24px → 40px */
--text-3xl: clamp(1.875rem, 1.29rem + 2.86vw, 3.75rem); /* 30px → 60px */
--text-4xl: clamp(2.25rem, 1.39rem + 4.29vw, 5rem); /* 36px → 80px */
--text-5xl: clamp(3rem, 1.57rem + 7.14vw, 7.5rem); /* 48px → 120px */
}
Using CSS custom properties for these values means you define the responsive logic once and it applies everywhere via inheritance.
Accessibility: Respecting User Font Size Preferences
Here's where fluid typography needs careful thought. There's a subtle accessibility trap in the common implementation.
The Problem with Pure vw
Consider a naive fluid font implementation:
/* Don't do this */
body {
font-size: 2vw; /* 100% viewport-dependent */
}
If a user has set their browser's default font size to 20px (because they need larger text), this setting is completely ignored. The vw unit is absolutely relative to the viewport, not to the user's preferences. Users who rely on enlarged text — those with low vision, for example — can't increase your text size through browser settings.
Why clamp() with rem Is Different
When you use clamp() with rem values for the minimum and maximum, those values respect the user's root font size. The viewport-relative preferred value is used only in the middle range.
/* This respects user preferences at the boundaries */
h1 {
font-size: clamp(2rem, 1.43rem + 2.86vw, 4rem);
/* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* min and max are rem — they scale with user preferences
* preferred uses vw — only active in the middle range
*/
}
If a user has set their browser font size to 24px (1.5x the default 16px):
- The 2rem minimum becomes 48px instead of 32px
- The 4rem maximum becomes 96px instead of 64px
- The user gets appropriately enlarged text throughout
The WCAG Requirement
WCAG 2.1 Success Criterion 1.4.4 (Resize Text) requires that text can be resized up to 200% without loss of content or functionality. This means:
/* Correct: min and max in rem, scales with user preferences */
p {
font-size: clamp(1rem, 0.93rem + 0.36vw, 1.25rem);
}
/* Non-compliant for accessibility: pure viewport units */
p {
font-size: clamp(14px, 1.5vw, 20px);
/* px values do NOT respect browser font size settings in most browsers */
}
Always use rem for the minimum and maximum arguments. The preferred value can use vw or a calc() that combines a rem component with a vw component — the latter is actually more accessible because even the scaling rate respects user preferences:
/* Even more accessible: rem + vw in the preferred value */
h1 {
font-size: clamp(2rem, 1rem + 3vw, 4rem);
/* The preferred value scales partly with user preference (1rem)
and partly with viewport (3vw) */
}
Testing Accessibility
To verify your fluid typography respects user settings: 1. Open browser settings and change the default font size (in Chrome: Settings → Appearance → Font size) 2. Reload your page and verify text sizes increased 3. Use browser zoom (Ctrl/Cmd + scroll or View → Zoom In) to test 200% zoom
Complete Fluid Type System Example
Here's a production-ready fluid type system combining everything covered above:
/* === Fluid Type System === */
/* Viewport range: 320px (mobile) → 1440px (large desktop) */
/* Scale: Major Third (1.25) at min, Perfect Fourth (1.333) at max */
:root {
/* Font families */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-serif: 'Lora', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
/* Fluid sizes — clamp(min, preferred, max) */
/* xs: 10px → 12px */
--text-xs: clamp(0.625rem, 0.55rem + 0.38vw, 0.75rem);
/* sm: 13px → 15px */
--text-sm: clamp(0.8125rem, 0.75rem + 0.27vw, 0.9375rem);
/* base: 16px → 20px */
--text-base: clamp(1rem, 0.93rem + 0.36vw, 1.25rem);
/* md: 19px → 26px */
--text-md: clamp(1.1875rem, 1.01rem + 0.89vw, 1.625rem);
/* lg: 22px → 34px */
--text-lg: clamp(1.375rem, 1.07rem + 1.52vw, 2.125rem);
/* xl: 28px → 48px */
--text-xl: clamp(1.75rem, 1.25rem + 2.5vw, 3rem);
/* 2xl: 36px → 64px */
--text-2xl: clamp(2.25rem, 1.5rem + 3.75vw, 4rem);
/* 3xl: 48px → 96px */
--text-3xl: clamp(3rem, 1.71rem + 6.43vw, 6rem);
/* Fluid line heights — tighten as text gets larger */
--leading-body: 1.65;
--leading-heading: 1.2;
/* Fluid letter spacing */
--tracking-heading: -0.02em;
--tracking-label: 0.08em;
}
/* === Base === */
html {
font-size: 16px; /* rem anchor */
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-body);
font-weight: 400;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* === Headings === */
h1 {
font-size: var(--text-3xl);
line-height: 1.05;
letter-spacing: var(--tracking-heading);
font-weight: 700;
}
h2 {
font-size: var(--text-2xl);
line-height: 1.1;
letter-spacing: -0.015em;
font-weight: 700;
}
h3 {
font-size: var(--text-xl);
line-height: 1.2;
letter-spacing: -0.01em;
font-weight: 600;
}
h4 {
font-size: var(--text-lg);
line-height: 1.3;
font-weight: 600;
}
h5 {
font-size: var(--text-md);
line-height: 1.4;
font-weight: 600;
}
h6 {
font-size: var(--text-base);
line-height: 1.5;
font-weight: 600;
letter-spacing: 0.01em;
}
/* === Body elements === */
p {
font-size: var(--text-base);
line-height: var(--leading-body);
max-width: 70ch;
}
.lead {
font-size: var(--text-md);
line-height: 1.5;
font-weight: 300;
}
blockquote {
font-size: var(--text-lg);
font-family: var(--font-serif);
font-style: italic;
line-height: 1.5;
}
/* === Small text === */
small, .text-sm {
font-size: var(--text-sm);
line-height: 1.5;
}
caption, .text-xs {
font-size: var(--text-xs);
line-height: 1.4;
}
/* === UI elements === */
button, input, select, textarea {
font-size: var(--text-base);
font-family: inherit;
}
.label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
}
/* === Code === */
code, pre, kbd {
font-family: var(--font-mono);
font-size: 0.875em; /* Relative to context, not scale */
}
/* === Prose override (long-form reading) === */
.prose {
font-family: var(--font-serif);
font-size: var(--text-md);
line-height: 1.75;
}
.prose h1, .prose h2, .prose h3 {
font-family: var(--font-sans);
}
Adding Optional Breakpoint Refinements
The system above works beautifully without any media queries. However, you may still want minor adjustments at extreme viewports for fine-tuned control:
/* Optional: fine-tune at very narrow viewports */
@media (max-width: 360px) {
:root {
--leading-body: 1.55; /* Slightly tighter on very small screens */
}
}
/* Optional: adjust line lengths on very wide viewports */
@media (min-width: 1200px) {
p {
max-width: 65ch;
}
}
These additions don't change the font sizes — only minor refinements to layout and spacing. The type scale handles itself.
The combination of clamp() for fluid sizes and CSS custom properties for the type scale gives you a system that is simultaneously simpler to write, more expressive at every viewport, and more respectful of user accessibility preferences than any breakpoint-based system can be.
CSS Typography Deep Dive
Typography Terms
Try These Tools
Fonts Mentioned
Designed by Christian Robertson for Google's Material Design ecosystem, this neo-grotesque sans-serif is the most widely used typeface on the web and Android. Its dual-nature design balances mechanical precision with natural reading rhythm, making it equally at home in UI labels and long-form text. The variable font supports width and weight axes alongside Cyrillic, Greek, and extended Latin scripts.
Rasmus Andersson spent years refining this neo-grotesque specifically for computer screens, optimizing letter spacing, x-height, and stroke contrast for high readability at small sizes on digital displays. An optical size axis (opsz) lets the font automatically adjust its design for captions versus headlines, while the weight axis covers the full range from thin to black. It has become the de facto choice for dashboards, documentation sites, and developer tools worldwide.