Viewport Units and Fluid Typography
vh Is Broken on Mobile -- And the Fix Changes Everything
100vh should mean "full viewport height." On desktop, it does. On mobile? It includes the area behind the browser's URL bar and bottom navigation. When the URL bar is visible, 100vh extends below the visible screen. Users see scrollbars on content that should fill the screen exactly. This bug (really a spec issue) persisted for years before the new viewport unit variants finally solved it.
Think of mobile browser viewport units as three measuring tapes. lvh (large) measures the viewport with the URL bar hidden — the maximum visible area. svh (small) measures with the URL bar visible — the minimum visible area. dvh (dynamic) reads the current state and updates live as the URL bar slides in and out. For most use cases, dvh is what you actually want — it matches what the user can see right now.
The Viewport Unit Variants
| Unit | Full Name | Description |
|---|---|---|
vh / vw | Viewport height/width | The original — problematic on mobile |
svh / svw | Small viewport | URL bar visible (smallest viewport) |
lvh / lvw | Large viewport | URL bar hidden (largest viewport) |
dvh / dvw | Dynamic viewport | Adapts as browser chrome appears/disappears |
vmin / vmax | Min/max of vh and vw | Responsive to orientation changes |
/* Hero section — actually fills the visible screen on mobile */
.hero {
min-height: 100dvh;
}
/* Alternative: svh for guaranteed no content cutoff */
.hero {
min-height: 100svh;
/* Content always visible, even with URL bar showing */
}
dvh causes layout reflow as the URL bar animates in and out. For elements that should not resize during scroll (like a fixed header background), use svh or lvh instead. dvh is best for hero sections and overlays where you want the element to match the visible area at all times.
When to Use Each
| Use Case | Unit | Why |
|---|---|---|
| Hero sections | 100dvh | Matches visible area as it changes |
| Fixed overlays/modals | 100dvh or 100svh | Never extends behind browser chrome |
| Background images | 100lvh | Avoids reflow during scroll |
| Sticky elements height calc | 100svh | Safe minimum for visible calculations |
Fluid Typography System
This is one of the most impactful things you can learn in modern CSS. No more juggling font-size media queries.
The clamp() Formula
h1 {
font-size: clamp(minimum, preferred, maximum);
font-size: clamp(1.5rem, 4vw + 0.5rem, 3rem);
}
How it works:
- minimum (1.5rem): Never smaller than this — protects readability
- preferred (4vw + 0.5rem): The fluid value — scales with viewport
- maximum (3rem): Never larger than this — prevents absurdly large text
The + 0.5rem in the preferred value is important: it sets the "base" so the text doesn't become too small on narrow viewports before hitting the minimum.
Complete Fluid Type Scale
:root {
--step--2: clamp(0.69rem, 0.66rem + 0.18vw, 0.80rem);
--step--1: clamp(0.83rem, 0.78rem + 0.29vw, 1.00rem);
--step-0: clamp(1.00rem, 0.91rem + 0.43vw, 1.25rem);
--step-1: clamp(1.20rem, 1.07rem + 0.63vw, 1.56rem);
--step-2: clamp(1.44rem, 1.26rem + 0.89vw, 1.95rem);
--step-3: clamp(1.73rem, 1.48rem + 1.24vw, 2.44rem);
--step-4: clamp(2.07rem, 1.73rem + 1.70vw, 3.05rem);
--step-5: clamp(2.49rem, 2.03rem + 2.31vw, 3.82rem);
}
body { font-size: var(--step-0); }
h1 { font-size: var(--step-5); }
h2 { font-size: var(--step-4); }
h3 { font-size: var(--step-3); }
h4 { font-size: var(--step-2); }
small, .caption { font-size: var(--step--1); }
Calculating the Preferred Value
The formula to create a fluid value between two sizes across a viewport range:
preferred = minimum + (maximum - minimum) * ((viewport - viewportMin) / (viewportMax - viewportMin))
In practice, use a tool like utopia.fyi to generate the clamp() values. But understanding the math:
/* Goal: 1.5rem at 320px viewport, 3rem at 1200px viewport */
/* Rate = (3 - 1.5) / (1200 - 320) = 1.5 / 880 = 0.17% per viewport px */
/* In vw: 0.17 * 100 = 17.05vw... that's too aggressive */
/* Better: mix rem and vw */
h1 { font-size: clamp(1.5rem, 1rem + 2.27vw, 3rem); }
Fluid Spacing
Why stop at fonts? Apply the same clamp() approach to spacing:
:root {
--space-xs: clamp(0.25rem, 0.2rem + 0.25vw, 0.5rem);
--space-sm: clamp(0.5rem, 0.4rem + 0.5vw, 1rem);
--space-md: clamp(1rem, 0.8rem + 1vw, 2rem);
--space-lg: clamp(1.5rem, 1rem + 2.5vw, 4rem);
--space-xl: clamp(2rem, 1rem + 5vw, 6rem);
}
.section { padding: var(--space-lg) var(--space-md); }
.card { gap: var(--space-sm); }
Why mixing rem and vw matters in clamp()
Using 4vw alone as the preferred value means at 320px viewport, the font is 12.8px — too small. At 1920px, it's 76.8px — absurdly large. Adding a rem component (4vw + 0.5rem) shifts the baseline up so the scaling starts from a reasonable size. The rem part provides the floor, the vw part provides the responsiveness.
| What developers do | What they should do |
|---|---|
| Using 100vh for full-screen elements on mobile 100vh includes the area behind the mobile URL bar — content gets cut off | Use 100dvh (dynamic) or 100svh (small) for mobile-safe viewport height |
| Using only vw for fluid font-size (like font-size: 4vw) Pure vw has no minimum or maximum — text becomes unreadable on small screens and absurd on large ones | Use clamp() with a rem minimum, vw+rem preferred, and rem maximum |
| Forgetting to add a rem component to the preferred clamp value Without the rem addition, the font size starts from 0 at 0vw, making it too small on small viewports | Mix rem + vw: clamp(1.5rem, 1rem + 2vw, 3rem) — rem provides the baseline |
| Using dvh for fixed backgrounds (causes reflow on mobile scroll) dvh updates continuously as the URL bar slides — causing layout reflow on every scroll | Use lvh for backgrounds and elements that shouldn't resize during URL bar animation |
- 1Use dvh for elements that should match the visible viewport height on mobile (heroes, modals)
- 2Use svh when you want the safe minimum viewport height (URL bar visible)
- 3Use lvh for backgrounds to avoid reflow during URL bar animation
- 4Build fluid typography with clamp(rem-min, rem + vw, rem-max) — never pure vw
- 5Apply the same fluid approach to spacing, padding, and gap with clamp()