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-width does.
  • Use clamp(), min() and max() 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.

Page, layout viewport and visual viewport The full page is a tall outline. Inside it the layout viewport is the rectangle CSS measures, sized between the small and large viewport heights. The visual viewport is the part on screen; when the keyboard opens it shrinks while the layout viewport stays the same. What CSS measures versus what the user sees the whole page layout viewport vw · svh · dvh Layout viewport what layout, fixed positioning and viewport units measure; height varies with toolbars Visual viewport what is on screen right now; shrinks for pinch-zoom and, by default, the keyboard

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.

The three heights across a scroll Two horizontal lines mark the constant small and large viewport heights. The dynamic height starts on the small line, rises to the large line when the toolbar hides mid-scroll, and falls back to the small line when the user scrolls up and the toolbar returns. Only dvh moves while the user scrolls time during one scroll lvh svh loaded, UI shown UI hidden UI back dvh

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

TokenAccepted valuesDefault / notes
vw, vh, vi, vb<number> unitClassic units; on mobile, vh behaves as the large viewport
svw, svh, svi, svb<number> unitSmall viewport: all toolbars shown
lvw, lvh, lvi, lvb<number> unitLarge viewport: toolbars hidden
dvw, dvh, dvi, dvb<number> unitDynamic: whichever is current
*vmin, *vmax<number> unitSmaller / larger of the two axes, for each family
env(safe-area-inset-*)top, right, bottom, left, optional fallback0px unless viewport-fit=cover
viewport-fit (meta)auto, contain, coverauto letterboxes inside the safe area
interactive-widget (meta)resizes-visual, resizes-content, overlays-contentBrowser 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.

Mobile failures and their fixes Content under the toolbar is fixed with svh. Gaps when the toolbar hides are fixed with lvh. Overlays not fitting are fixed with dvh. Controls under the notch or indicator are fixed with safe-area insets. Horizontal scrollbars from full-width bands are fixed with the named-line grid. Five mobile failures, five fixes Call to action hidden under the toolbar on load 100svh Bare strip when the toolbar retracts 100lvh Overlay taller or shorter than the screen 100dvh Buttons under the notch or home indicator max(pad, env(…)) Horizontal scrollbar from a 100vw band named-line grid

DevTools Debugging Workflow

  1. 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, lvh and dvh all report the same value there. Test viewport-height behaviour on a real device or a platform simulator.
  2. Remote debugging. Connect an Android device to Chrome via chrome://inspect, or an iPhone to Safari's Develop menu, and inspect the live page. Evaluate innerHeight and visualViewport.height in the console while scrolling to watch the two viewports diverge.
  3. 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 uses max() shows its resolved pixel value, which confirms which side of the max() won.
  4. Scrollbar overhang. On Windows or Linux, or with macOS set to always show scrollbars, compare document.documentElement.clientWidth with innerWidth; the difference is the scrollbar width that 100vw includes.
  5. 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-content changes that where supported.

Browser Compatibility

FeatureChrome / EdgeFirefoxSafari
svh / lvh / dvh and variants108+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 lines57+ / 16+52+10.1+
env(safe-area-inset-*)Supported on current mobile versionsSupported on current mobile versionsSupported 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

PitfallCauseResolution
Hero's bottom cut off on load100vh is the large viewport on mobilemin-height: 100svh with a vh fallback
Content jumps while scrollingIn-flow element sized with dvhUse svh for in-flow, dvh only for overlays
Horizontal scrollbar on Windows100vw includes the scrollbarNamed-line grid, or cqi on a container
Insets always zeroMissing viewport-fit=coverAdd it to the viewport meta tag
Doubled bottom padding on iPhonecalc(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 articles

More pages in the same section.