Viewport Units & Mobile Layout: Designing for the Screen You Actually Get
Desktop CSS grew up with a simple model of the viewport: a rectangle the size of the browser window, stable while the page scrolls. Phones broke every part of that model. The visible area changes as toolbars slide away, the on-screen keyboard covers half the page without resizing it, pinch-zoom shows a small part of the layout, and screens have notches, rounded corners and gesture bars. This guide, part of Mastering Container Queries & Responsive Layouts, explains the mobile viewport as the browser actually implements it and the CSS features built to work with it — the new viewport units, safe-area insets, edge-to-edge sections — and when to stop measuring the viewport altogether and use container query units instead.
Prerequisites — this guide assumes you can already:
- Write a viewport meta tag and explain what
width=device-widthdoes. - Use
clamp(),min()andmax()in length values. - Build a basic grid layout with
grid-template-columns. - Distinguish a size container from an inline-size container (see container-type: size vs inline-size).
The Core Concept: Two Viewports, Three Heights
Browsers maintain two viewports. The layout viewport is the rectangle CSS layout is computed against: the initial containing block, the reference for position: fixed, and the box that vw, vh and their relatives measure. The visual viewport is the portion of the page actually on screen right now. On desktop they are the same rectangle. On mobile they diverge whenever the user pinch-zooms (the visual viewport shrinks to a region of the layout) or opens the on-screen keyboard (the visual viewport shrinks, the layout viewport by default does not).
The layout viewport itself changes height as mobile toolbars retract and reappear. The CSS Values specification names the three heights that matter: the small viewport with all toolbars shown, the large viewport with them hidden, and the dynamic viewport, which is whichever is current.
Every mobile layout problem in this section maps onto that picture. dvh, svh and lvh are the three heights of the layout viewport. Safe Area Insets for Notched Screens describes the parts of the layout viewport that hardware obscures. Full-Bleed Sections in Constrained Layouts deals with the width of the layout viewport and why 100vw includes a scrollbar. And Viewport Units vs Container Units covers the many cases where the viewport is simply the wrong reference box.
What happens during one scroll
It helps to watch the three heights over the course of a single gesture. The page loads with toolbars visible. The user scrolls down; partway through, the browser slides its toolbars away. The user scrolls back up a little; the toolbars return. The small and large heights never change during any of this — they are properties of the device and browser, not of the moment. Only the dynamic height moves.
That chart is the whole argument for choosing units by element type. Anything drawn with the dashed lines — svh or lvh — is laid out once and stays put. Anything drawn with the solid line is laid out again at each step. For an overlay that must match the screen, re-layout is the requirement; for a hero in the document flow, it is a layout shift that the user sees as content jumping under their finger.
Where container units take over
Viewport units answer questions about the screen. Many sizing questions that used to be answered with viewport units were never really about the screen: they were about the component, and the viewport was used because it was the only box CSS could measure. A card's heading scaled in vw is correct only while the card is roughly as wide as the viewport, which on a multi-column desktop layout it never is.
Container query units measure the nearest size container instead, and they fall back to the small viewport when no container exists, so they are strictly more portable for component-level sizing. The practical split is simple enough to apply during code review: if moving the component into a sidebar should change the value, it belongs to the component and should use cqi; if it should not, it belongs to the screen and a viewport unit is right. Heights are almost always screen-owned — nothing about a card's width says how tall an overlay may be — which is why the viewport height units remain essential even in a fully container-query-driven design system.
Syntax and Parameters
| Token | Accepted values | Default / notes |
|---|---|---|
vw, vh, vi, vb | <number> unit | Classic units; on mobile, vh behaves as the large viewport |
svw, svh, svi, svb | <number> unit | Small viewport: all toolbars shown |
lvw, lvh, lvi, lvb | <number> unit | Large viewport: toolbars hidden |
dvw, dvh, dvi, dvb | <number> unit | Dynamic: whichever is current |
*vmin, *vmax | <number> unit | Smaller / larger of the two axes, for each family |
env(safe-area-inset-*) | top, right, bottom, left, optional fallback | 0px unless viewport-fit=cover |
viewport-fit (meta) | auto, contain, cover | auto letterboxes inside the safe area |
interactive-widget (meta) | resizes-visual, resizes-content, overlays-content | Browser default is usually resizes-visual |
The logical variants (vi, vb and their prefixed forms) follow the writing mode: in horizontal text vi is width and vb is height, and in vertical writing modes they swap. Use them in components that may be rendered in vertical scripts.
Step-by-Step Implementation: A Mobile-Ready Landing Page
Step 1: the viewport meta tag
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
width=device-width makes the layout viewport match the device width in CSS pixels. viewport-fit=cover extends drawing under notches and indicators, which is only safe once step 4 is in place.
Step 2: a hero that fits on load
.hero {
min-height: 100vh; /* fallback */
min-height: 100svh; /* never taller than the visible area on load */
display: grid;
align-content: end;
padding: 1.5rem;
}
svh is stable during scrolling, so the hero never jumps as the toolbar retracts.
Step 3: a full-bleed image inside a reading column
.page {
display: grid;
grid-template-columns:
[full-start] minmax(1rem, 1fr)
[content-start] min(65ch, 100% - 2rem)
[content-end] minmax(1rem, 1fr)
[full-end];
}
.page > * { grid-column: content; }
.page > .full-bleed { grid-column: full; }
No 100vw means no scrollbar overhang on desktop platforms.
Step 4: respect the safe areas
.page > * {
padding-inline: max(0px, env(safe-area-inset-left)) max(0px, env(safe-area-inset-right));
}
.page > .full-bleed { padding-inline: 0; } /* images may run under the notch */
.bottom-bar {
position: fixed;
inset: auto 0 0 0;
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
Step 5: a sheet that always fits
.sheet {
position: fixed;
inset: 0;
height: 100vh;
height: 100dvh; /* matches the visible area as the toolbar moves */
overflow: auto;
overscroll-behavior: contain;
}
Each step uses the unit whose failure mode is acceptable for that element: stable svh for in-flow content, exact dvh for an out-of-flow overlay, and no viewport width at all for the full-bleed image.
Annotated Production Example: A Bottom Sheet With a Composer
A bottom sheet with a text field is the hardest mobile layout to get right, because it combines a dynamic height, a keyboard, a home indicator and a scrolling body. The component below handles all four.
<!-- In the head: allow the layout viewport to shrink for the keyboard. -->
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<section class="sheet" aria-label="Comments">
<header class="sheet__header"><h2>Comments</h2></header>
<div class="sheet__body" tabindex="0">
<p>First comment…</p>
</div>
<form class="sheet__composer">
<label class="visually-hidden" for="c">Add a comment</label>
<input id="c" name="comment" autocomplete="off">
<button type="submit">Post</button>
</form>
</section>
.sheet {
position: fixed;
inset: auto 0 0 0;
/* Up to 85% of whatever is visible now; with resizes-content the
keyboard shrinks the layout viewport, so dvh shrinks too. */
max-height: 85vh;
max-height: 85dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
border-radius: 16px 16px 0 0;
background: #ffffff;
box-shadow: 0 -8px 24px rgb(15 23 42 / 0.15);
}
.sheet__header {
padding: 1rem max(1rem, env(safe-area-inset-right)) 0.5rem max(1rem, env(safe-area-inset-left));
}
.sheet__body {
overflow: auto;
overscroll-behavior: contain; /* scrolling the sheet never scrolls the page */
padding-inline: max(1rem, env(safe-area-inset-left)) max(1rem, env(safe-area-inset-right));
}
.sheet__body:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
.sheet__composer {
display: flex;
gap: 0.5rem;
padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
border-top: 1px solid #e2e8f0;
}
.sheet__composer input { flex: 1; min-width: 0; font-size: 1rem; }
.sheet__composer button { min-height: 44px; min-width: 44px; }
@media (prefers-reduced-motion: no-preference) {
.sheet { animation: sheet-in 0.25s ease-out; }
}
@keyframes sheet-in { from { translate: 0 100%; } }
Four decisions carry the component. minmax(0, 1fr) on the middle row lets the body shrink and scroll instead of pushing the composer off screen. The composer's bottom padding uses max() with the inset so its button clears the home indicator. The input's font-size: 1rem avoids an automatic zoom that some mobile browsers apply to inputs with text smaller than 16 pixels. And the entrance animation is gated on prefers-reduced-motion, following the approach in Reducing Motion Preferences in CSS.
Integration With Adjacent CSS Features
Media queries. Viewport units and viewport media queries measure the same box, so a @media (height < 30rem) query is the natural companion for short landscape phones where even 100svh heroes are too tall to hold their content. Range syntax such as (width >= 40rem) keeps these conditions readable.
Scroll snapping. Full-screen snap sections — story-style pages where each panel fills the screen — should use 100svh panels with scroll-snap-align: start. Using dvh makes the snap points move as the toolbar retracts, which feels like the page fighting the user's thumb. The overflow and snapping patterns are covered in Scroll Snap & Overflow Layouts.
Sticky positioning. A position: sticky header sticks to the top of the layout viewport. Safe-area padding belongs on the sticky element itself, so its background extends under the status bar while its content clears it.
Scroll-driven animations. A scroll() timeline on the root scroller measures the document's scroll range, which itself depends on the viewport height. Progress indicators therefore stay correct as the toolbar moves, but a timeline range written in viewport units inherits the same trade-offs as any other length — prefer percentages of the timeline.
Cascade layers and tokens. Put viewport-dependent tokens — hero height, sheet maximum height — in a tokens layer as custom properties, with the vh fallback and the modern unit declared together. Components then reference var(--hero-min) and never repeat the fallback pair. When a later redesign changes the hero from svh to a clamped value, the change happens in one place and every page that uses the token follows.
Performance and Accessibility Notes
Dynamic units cost layout. Anything sized with dvh is re-laid out as the toolbars move. Browsers throttle these updates, but in-flow content sized with dvh still shifts the content below it mid-scroll and can register as layout shift. Reserve dvh for out-of-flow elements.
Zoom and text. Pure viewport-unit font sizes barely respond to browser zoom, which fails WCAG 1.4.4 Resize Text. Always combine them with rem in clamp().
Reflow at 320 pixels. WCAG 1.4.10 asks that content reflow without horizontal scrolling at 320 CSS pixels wide. Minimum widths in vw are harmless, but fixed minimum widths in pixels or rem on full-width elements, and 100vw elements on desktop platforms, commonly break it.
Operable targets near edges. Controls under the home indicator or in a notch's shadow are hard to activate. Safe-area padding and 44-pixel targets together keep bottom bars usable, as covered in Target Size and Pointer Accessibility.
Scrollable regions need focus. A sheet body that scrolls must be keyboard reachable, which is why the example gives it tabindex="0" and a visible focus style.
DevTools Debugging Workflow
- Device emulation is not enough for toolbars. Chrome's and Firefox's responsive modes emulate width and device pixel ratio but not retracting toolbars, so
svh,lvhanddvhall report the same value there. Test viewport-height behaviour on a real device or a platform simulator. - Remote debugging. Connect an Android device to Chrome via
chrome://inspect, or an iPhone to Safari's Develop menu, and inspect the live page. EvaluateinnerHeightandvisualViewport.heightin the console while scrolling to watch the two viewports diverge. - Safe areas. The iOS Simulator and Android emulators with display cut-outs report real
env(safe-area-inset-*)values. In the Computed pane, padding that usesmax()shows its resolved pixel value, which confirms which side of themax()won. - Scrollbar overhang. On Windows or Linux, or with macOS set to always show scrollbars, compare
document.documentElement.clientWidthwithinnerWidth; the difference is the scrollbar width that100vwincludes. - Keyboard behaviour. Focus an input near the bottom of the page on a device and watch whether fixed elements move. If they stay put and are covered, the browser is overlaying the keyboard;
interactive-widget=resizes-contentchanges that where supported.
Browser Compatibility
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
svh / lvh / dvh and variants | 108+ | 101+ | 15.4+ |
Container query units (cqi, cqb) | 105+ | 110+ | 16+ |
min() / max() / clamp() | 79+ | 75+ | 13.1+ |
subgrid (nested full-bleed) | 117+ | 71+ | 16+ |
| CSS Grid named lines | 57+ / 16+ | 52+ | 10.1+ |
env(safe-area-inset-*) | Supported on current mobile versions | Supported on current mobile versions | Supported on current versions |
interactive-widget in the viewport meta tag is honoured by Chromium-based mobile browsers and ignored elsewhere, so treat it as an enhancement.
Common Pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Hero's bottom cut off on load | 100vh is the large viewport on mobile | min-height: 100svh with a vh fallback |
| Content jumps while scrolling | In-flow element sized with dvh | Use svh for in-flow, dvh only for overlays |
| Horizontal scrollbar on Windows | 100vw includes the scrollbar | Named-line grid, or cqi on a container |
| Insets always zero | Missing viewport-fit=cover | Add it to the viewport meta tag |
| Doubled bottom padding on iPhone | calc(pad + inset) | max(pad, inset) |
FAQ
What is the difference between the layout viewport and the visual viewport? The layout viewport is the box that CSS layout, fixed positioning and viewport units are measured against. The visual viewport is the part of the page currently visible on screen, which shrinks when the user pinch-zooms or the on-screen keyboard opens. Most layout rules only ever see the layout viewport.
Why does my full-height section overflow on mobile but not on desktop?
Because 100vh on mobile browsers means the height with the retractable toolbars hidden. When the page loads, the toolbars are showing and the visible area is shorter, so the section extends below the fold. Use 100svh for sections that must fit on load.
Do I still need the viewport meta tag?
Yes. Without width=device-width, mobile browsers lay pages out at a desktop-like width and scale them down, so every viewport unit and media query sees the wrong width. Add viewport-fit=cover only if you also handle safe-area insets.
Are viewport units bad for accessibility?
Not in themselves, but font sizes in pure vw fail WCAG 1.4.4 because browser zoom barely changes them. Combine viewport units with rem inside clamp() for text, and prefer svh over vh for heights so content is not hidden under browser UI.
When should I use container units instead of viewport units?
Whenever the dimension belongs to a component rather than to the screen. Padding, gaps and heading sizes of a card should follow the card's width via cqi; the maximum height of an overlay or the height of a page hero should follow the screen.
Related
- dvh, svh and lvh — the three viewport heights and when to use each.
- Safe Area Insets for Notched Screens — edge-to-edge drawing done safely.
- Full-Bleed Sections in Constrained Layouts — breaking out of a reading column.
- Viewport Units vs Container Units — choosing the right reference box.
- Fluid Typography With clamp() — type scales built on these units.
- Scroll-Driven Animations — motion tied to the same scrolling viewport.
Related articles
More pages in the same section.