100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind
You give a chat screen h-screen, put the composer at the bottom of a flex column, and on an iPhone the composer is gone. It sits under Safari's toolbar, and the whole page scrolls by the height of that toolbar. That is the 100vh mobile bug, and browsers behave this way on purpose. This tutorial explains what 100vh measures on a phone, when to use dvh, svh or lvh in Tailwind CSS, and how to build a full-height shell, sheets and a keyboard-aware bar that stay put when the address bar moves. The code is Next.js App Router and Tailwind v4, with the Wingo UI components that follow the same rules.
Why is 100vh taller than the screen on mobile?
Because vh is measured with the browser toolbars hidden. A mobile browser has three viewport heights: small, with the address bar and toolbars expanded; large, with them retracted; and dynamic, whichever applies right now. MDN's length reference (opens in a new tab) is explicit: "Currently, all default viewport units (vh, vw, etc.) are equivalent to their large viewport counterparts (lvh, lvw, etc.)."
Every page loads with the toolbars showing, so 100vh starts out taller than the visible area by their height. Tailwind's h-screen compiles to exactly that, 100vh, in Tailwind 4.3 as in v3.
This is the classic 100vh mobile Safari complaint. Since iOS 15, Safari puts its address bar at the bottom by default, so the strip that 100vh hides is where your primary button lives. Chrome on Android follows the same model on purpose: when Chrome 56 changed vh, the Chrome team wrote (opens in a new tab) that the new model "should match how Safari behaves."
On a desktop, and in a web app installed to the home screen, no toolbars come and go, so the three heights are equal and the bug never shows up.
What is the difference between dvh, svh and lvh?
svh is the height with the toolbars showing, lvh the height with them hidden, and dvh whichever is true at the moment. All three are percentages, so 100dvh is the full dynamic height:
The dynamic unit has two catches. Browsers throttle it: web.dev's introduction to the units (opens in a new tab) says the values "do not update at 60fps." And everything sized with it reflows when it does update. WebKit bug 266835 (opens in a new tab), filed against iOS 17.2.1 and titled "dvh causes scroll jank as the address bar resizes when scrolling", shows full-height dvh sections that "don't resize to the larger viewport height until after scrolling has finished," so the layout jumps once the scroll ends. As of October 2026 the report is still open.
Support is no longer a reason to wait. Safari 15.4, Firefox 101 and Chrome 108 shipped all three units, and caniuse (opens in a new tab) counts about 95% of global usage as supported as of October 2026. The Tailwind dvh, svh and lvh classes arrived in v3.4 (opens in a new tab), and Tailwind v4 itself requires (opens in a new tab) Safari 16.4, Chrome 111 and Firefox 128, so a v4 project needs no vh fallback.
When should you use dvh, svh or lvh?
Use dvh for what stays on screen, svh for what scrolls away, lvh only for fixed backgrounds, and no unit at all for a fixed element pinned to two opposite edges. Applied to the usual parts of an app:
- App shell root:
min-h-dvhwhen the window scrolls,h-dvhwhen an inner column scrolls. - Bottom sheets, popovers, menus: a cap such as
max-h-[92dvh]. - Side panels, overlays, full-screen menus:
fixed inset-0orfixed inset-y-0. A fixed element sized by its edges follows the visible area as the toolbars move; Chrome kept that behavior when it changedvhin version 56. - Marketing hero or first screen:
min-h-svh. It fills the screen on load with the toolbars showing and never grows under the reader's thumb. - Fixed background image or canvas:
fixed inset-x-0 top-0 h-lvh. As tall as the screen can get, it never uncovers a gap and never resizes.
That settles 100dvh vs 100vh: we have not found a case where plain vh is the better choice, and when you want its behavior, lvh says so. No Wingo UI component uses h-screen or sizes a full-height layout in vh; the full-height ones use min-h-dvh, h-dvh and dvh caps.
If an existing codebase is full of h-screen, you can retarget the class in one place. In Tailwind v4, a custom utility with the name of a core utility adds its declarations after the core ones:
With Tailwind 4.3.3 this compiles h-screen and md:h-screen to height: 100vh; height: 100dvh;, so a browser without dvh keeps the old value. It also turns every hero into a dvh hero, so follow it up: search for h-screen and pick a unit for each one.
How do you build a full-height app shell that does not jump?
Let the document scroll, give the shell min-h-dvh, pin the header with sticky and the tab bar with fixed. Nothing on the page then depends on the toolbar height, so nothing moves when it changes.
Step 1: let the page reach the screen edges
Opt into the area around the notch and the home indicator, so env(safe-area-inset-*) returns real values. In the App Router that is the viewport export of the root layout:
The Tailwind safe area guide covers the insets in depth; here they only pad the bars.
Step 2: a shell that scrolls the window
min-h-dvh only matters when the page is shorter than the screen, where it ends the content at the bottom of the visible area, and a page that short does not scroll, so the toolbars stay put. A taller page is taller than the minimum either way, so the toolbars come and go during a scroll without changing its height, and the fixed tab bar tracks the visible area on its own.
Window scroll is also the setup in which Safari reliably minimizes its toolbars. With an inner scroll container that behavior has changed between iOS releases: WebKit bugs 228278 (opens in a new tab) and 231878 (opens in a new tab), both from the iOS 15 cycle, report opposite behavior, and both were closed as intended. Do not count on the extra space there.
Step 3: a shell with its own scroll area
A chat or a board needs the header and the composer on screen at all times. Make the column exactly the visible height and let one child scroll:
This is the broken screen from the introduction with h-dvh in place of h-screen. min-h-0 lets the middle child shrink, so it scrolls instead of pushing the composer down.
The App Shell builds both versions from one prop. scroll="window", the default, renders the root with min-h-dvh; scroll="content" renders h-dvh overflow-hidden. With window scroll its desktop sidebar is sticky top-0 h-dvh, so the sidebar's footer stays in view on a long page. Its docs give the reason for the default: window scroll keeps "router scroll restoration, the iOS toolbar minimizing."
How tall should a bottom sheet be on mobile?
At most 92dvh: tall enough for a long list, short enough to leave a strip of the dimmed page above it, and in dvh so the address bar never pushes the handle off the screen. While a modal sheet is open the page behind it is locked, so the toolbars rarely move, and if they do, a dvh cap follows them.
Side panels need no unit at all: the free Sheet places them with fixed inset-y-0 and pads the top with env(safe-area-inset-top). For bottom sheets, two details decide the cap:
- The unit. A cap of
92vhis 92% of the large viewport. Whenever the toolbars take more than 8% of the screen, that is taller than the visible area, and the handle and the title start above the top of the screen. - The 8% gap. The strip of dimmed page above the sheet shows this is a layer, gives people somewhere to tap to dismiss it, and keeps the handle clear of the status bar.
In plain Tailwind that is fixed inset-x-0 bottom-0 flex max-h-[92dvh] flex-col on the panel, min-h-0 flex-1 overflow-y-auto on the body so a long list scrolls inside the cap, and pb-[max(env(safe-area-inset-bottom),16px)] on the last part. The React bottom sheet guide builds that layout on vaul with drag and focus handling.
The free Drawer is that layout on vaul, with drag to dismiss, focus handling and the safe-area padding built in. Its sheets grow with their content up to max-h-[92dvh], and height="full" keeps them at h-[92dvh] even when a search shortens the list:
$ npx wingo-ui@latest add drawerWhen a sheet must cover the whole screen, size it h-dvh and pad the top inset, which is what the Sheet does as a bottom sheet with size="full". Its smaller sizes cap at 50, 70, 85 or 92 dvh.
$ npx wingo-ui@latest add sheetDoes dvh change when the on-screen keyboard opens?
No. Safari on iOS, Chrome for Android since version 108 (opens in a new tab) and Firefox for Android since version 132 (opens in a new tab) shrink only the visual viewport when the keyboard opens. The layout viewport keeps its size, so svh, dvh and lvh keep their values, and the keyboard covers the bottom of your h-dvh layout.
On Android you can opt out: interactiveWidget: "resizes-content" in the Next.js viewport export (the interactive-widget key of the viewport meta tag) makes the keyboard shrink the layout viewport and every viewport unit with it, so the chat layout above keeps its composer above the keyboard without JavaScript. As of October 2026, caniuse (opens in a new tab) lists no Safari support, so iPhones still need code.
That code reads window.visualViewport: while a text field has focus, the keyboard covers innerHeight - visualViewport.height - visualViewport.offsetTop pixels. The useKeyboardInset hook measures that at most once per frame and returns it as a motion value, plus an open flag that re-renders only when it flips. For CSS-only consumers it can also write the value to a variable on <html>:
The bar moves with a transform, so nothing reflows as the keyboard slides. The hook reports 0 on the server, on desktops, while pinch-zoomed and whenever no text field has focus, and it ignores a covered height of 80px or less (its threshold), such as the iOS form accessory bar alone. Inside a bottom sheet you need none of this: the Drawer's repositionInputs, on by default, moves the sheet up so the focused field stays visible. The rest of a chat screen, from auto-scroll while a reply streams to Enter during IME input, is in our React AI chat UI guide.
$ npx wingo-ui@latest add use-keyboard-insetShould you still use the --vh JavaScript hack?
No. The hack sets a --vh variable from window.innerHeight in a resize listener. Until that script runs, the server-rendered page uses the fallback, so the layout can shift on load. After that it re-measures as the toolbars move, so it jumps during a scroll just as dvh can, with a trip through the main thread on top. Every browser Tailwind v4 supports has the native units, so delete the hack, and height: -webkit-fill-available with it.
How do you test viewport heights on a real phone?
On the phone itself, with a probe on the page. Device emulation in a desktop browser has no toolbars that retract, so the three units are equal there and every bug in this post stays hidden. This client component prints the three heights, innerHeight and the visual viewport:
Then, on an iPhone and on an Android phone: load the page and check that nothing important sits under the toolbar; scroll until the toolbars minimize and back, watching for content that shifts; open every sheet and find its handle and its last button; focus a text field and see what the keyboard covers; and rotate to landscape, where the screen is short and every toolbar pixel counts.
How do you fix 100vh in an existing app?
Search your code for h-screen and 100vh and give each match the unit for its job: min-h-dvh or h-dvh for shells, min-h-svh for heroes, inset-0 for fixed overlays. Then install the free Drawer and Sheet with npx wingo-ui@latest add drawer sheet; both cap their height in dvh and pad the safe areas. The App Shell and the useKeyboardInset hook are part of Wingo UI Pro. For the other phone rules, from bottom sheets to touch targets, read the mobile-first React components guide or browse Mobile UI in React.
FAQ
Why is 100vh taller than the screen on mobile?
Every major browser computes vh against the large viewport, the size with the address bar and toolbars retracted. While they are showing, 100vh overshoots the visible area by their height, so a button at the bottom of an h-screen layout ends up under the toolbar.
What is the difference between 100dvh and 100vh?
100vh is fixed at the large viewport height. 100dvh follows the toolbars: it equals 100svh while they are expanded and 100lvh once they retract, so it tracks the visible height, at the cost of resizing while the page scrolls.
Does Tailwind CSS support dvh?
Yes. Since Tailwind CSS v3.4 it ships h-dvh, h-svh and h-lvh with min-h and max-h versions, and arbitrary values such as max-h-[92dvh] work too. h-screen still compiles to height: 100vh in Tailwind 4.3.
Does dvh change when the on-screen keyboard opens?
Not by default. Safari on iOS, Chrome for Android since version 108 and Firefox for Android since version 132 resize only the visual viewport for the keyboard, so viewport units keep their values. Android browsers let you opt in with interactive-widget=resizes-content; on iOS, read window.visualViewport.
Is dvh supported in Safari?
Yes, since Safari 15.4 on macOS and iOS. Chrome and Edge added the units in version 108 and Firefox in version 101, and Tailwind CSS v4's own baseline (Safari 16.4, Chrome 111, Firefox 128) is newer than all of them.
Are the Wingo UI Sheet and Drawer free?
Yes. The Sheet and the Drawer are free and already cap their height in dvh and pad the safe areas. The App Shell and the useKeyboardInset hook are Pro.
- Mobile UI
- Tailwind CSS
- CSS
- React