WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. 100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind
  1. Blog
  2. Mobile UI in React
  3. 100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind
Mobile UI in React
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. Why is 100vh taller than the screen on mobile?
  2. What is the difference between dvh, svh and lvh?
  3. When should you use dvh, svh or lvh?
  4. How do you build a full-height app shell that does not jump?
    1. Step 1: let the page reach the screen edges
    2. Step 2: a shell that scrolls the window
    3. Step 3: a shell with its own scroll area
  5. How tall should a bottom sheet be on mobile?
  6. Does dvh change when the on-screen keyboard opens?
  7. Should you still use the --vh JavaScript hack?
  8. How do you test viewport heights on a real phone?
  9. How do you fix 100vh in an existing app?
  10. FAQ
    1. Why is 100vh taller than the screen on mobile?
    2. What is the difference between 100dvh and 100vh?
    3. Does Tailwind CSS support dvh?
    4. Does dvh change when the on-screen keyboard opens?
    5. Is dvh supported in Safari?
    6. Are the Wingo UI Sheet and Drawer free?

TL;DR

On phones, 100vh (Tailwind's h-screen) is measured with the browser toolbars hidden, so it is taller than the visible screen and the bottom of the layout sits under the address bar. In Tailwind, use min-h-dvh or h-dvh for app shells, a cap such as max-h-[92dvh] for bottom sheets, min-h-svh for heroes that scroll away, h-lvh only for fixed backgrounds, and fixed inset-0 for overlays. No viewport unit shrinks for the on-screen keyboard by default, so a bar that must stay above it needs window.visualViewport.

Published Oct 9, 2026

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:

Unitdvhsvhlvh
MeasuresThe visible height right nowThe height with the toolbars expandedThe height with the toolbars retracted, same as vh
Changes while the page scrollsYes, as the toolbars moveNoNo
Tailwind classesh-dvh, min-h-dvh, max-h-dvhh-svh, min-h-svh, max-h-svhh-lvh, min-h-lvh, max-h-lvh
Use it forShells, sheets, anything that stays on screenHeroes and first screens that scroll awayFixed backgrounds that must never show a gap

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-dvh when the window scrolls, h-dvh when an inner column scrolls.
  • Bottom sheets, popovers, menus: a cap such as max-h-[92dvh].
  • Side panels, overlays, full-screen menus: fixed inset-0 or fixed inset-y-0. A fixed element sized by its edges follows the visible area as the toolbars move; Chrome kept that behavior when it changed vh in 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:

css
/* app/globals.css: a stopgap that turns every h-screen into dvh */
@utility h-screen {
height: 100dvh;
}
@utility min-h-screen {
min-height: 100dvh;
}

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:

tsx
// app/layout.tsx
import type { Viewport } from "next";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};

The Tailwind safe area guide covers the insets in depth; here they only pad the bars.

Step 2: a shell that scrolls the window

tsx
// app/(app)/layout.tsx
import type { ReactNode } from "react";
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-dvh flex-col bg-background">
<header className="sticky top-0 z-40 bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="flex h-14 items-center px-4 font-semibold">Inbox</div>
</header>
{/* room for the fixed tab bar, so the last row can scroll above it */}
<main className="flex-1 px-4 pb-[calc(64px+env(safe-area-inset-bottom))]">{children}</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 z-40 flex h-[calc(64px+env(safe-area-inset-bottom))] justify-around border-t border-border bg-background pb-[env(safe-area-inset-bottom)]"
>
{/* tab links */}
</nav>
</div>
);
}

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:

tsx
import type { ReactNode } from "react";
export function ChatLayout({ messages, composer }: { messages: ReactNode; composer: ReactNode }) {
return (
<div className="flex h-dvh flex-col overflow-hidden bg-background">
<header className="shrink-0 border-b border-border pt-[env(safe-area-inset-top)]">
<div className="flex h-14 items-center px-4 font-semibold">Emma Carter</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">{messages}</div>
<footer className="shrink-0 border-t border-border px-3 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]">
{composer}
</footer>
</div>
);
}

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."

tsx
// app/(app)/layout.tsx
import type { ReactNode } from "react";
import { House, Inbox, Settings, Users } from "lucide-react";
import { AppShell } from "@/components/ui/app-shell";
import type { SidebarNavGroup } from "@/components/ui/sidebar";
import { Topbar } from "@/components/ui/topbar";
import type { NavItem } from "@/lib/navigation";
const nav: SidebarNavGroup[] = [
{
items: [
{ label: "Home", href: "/", icon: <House /> },
{ label: "Inbox", href: "/inbox", icon: <Inbox /> },
{ label: "Clients", href: "/clients", icon: <Users /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
],
},
];
const tabs: NavItem[] = nav[0].items;
export default function AppLayout({ children }: { children: ReactNode }) {
// "content" for a chat or a board; the default "window" lets Safari minimize its toolbars
return (
<AppShell nav={nav} tabs={tabs} topbar={<Topbar title="Summit CRM" />} scroll="window">
{children}
</AppShell>
);
}

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 92vh is 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:

tsx
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose } from "@/components/ui/drawer";
export function Filters({ results }: { results: number }) {
return (
<Drawer
title="Filters"
description="New York · 1,240 listings"
height="full"
trigger={<Button variant="soft">Filters</Button>}
footer={
<DrawerClose asChild>
<Button size="lg" fullWidth>
Show {results} results
</Button>
</DrawerClose>
}
>
<p className="text-sm text-muted-foreground">Price, condition and categories</p>
</Drawer>
);
}
Tap Filters: the sheet rises to 92% of the visible height and keeps it while you search the categories, leaving a strip of the dimmed page above it. Drag it down or tap that strip to close it
$ npx wingo-ui@latest add drawer
FreeDrawer docs

When 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.

Tap Filters: this bottom sheet is size full, so it fills exactly the visible height and pads the status bar; close it with the X or press Esc
$ npx wingo-ui@latest add sheet
FreeSheet docs

Does 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>:

tsx
"use client";
import { useKeyboardInset } from "@/hooks/use-keyboard-inset";
// mount once near the root: --keyboard-inset is the covered height in px, 0 without a keyboard
export function KeyboardInsetVar() {
useKeyboardInset({ cssVar: "--keyboard-inset" });
return null;
}
tsx
<form className="fixed inset-x-0 bottom-0 translate-y-[calc(var(--keyboard-inset,0px)*-1)] bg-background px-3 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]">
{/* message field and send button */}
</form>

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.

On a phone, tap the message field: the composer rides on top of the keyboard and the readout shows the covered height. On a desktop there is no on-screen keyboard, so the inset stays at 0px
$ npx wingo-ui@latest add use-keyboard-inset
ProuseKeyboardInset docs

Should 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:

tsx
"use client";
import { useEffect, useRef, useState } from "react";
const UNITS = ["svh", "dvh", "lvh"] as const;
export function ViewportProbe() {
const probes = useRef<Partial<Record<(typeof UNITS)[number], HTMLDivElement | null>>>({});
const [text, setText] = useState("");
useEffect(() => {
const visual = window.visualViewport;
const read = () => {
const units = UNITS.map((unit) => `${unit} ${probes.current[unit]?.offsetHeight ?? 0}`);
setText([...units, `inner ${window.innerHeight}`, `visual ${Math.round(visual?.height ?? 0)}`].join(" · "));
};
read();
window.addEventListener("resize", read);
visual?.addEventListener("resize", read);
return () => {
window.removeEventListener("resize", read);
visual?.removeEventListener("resize", read);
};
}, []);
return (
<>
{UNITS.map((unit) => (
<div
key={unit}
ref={(node) => {
probes.current[unit] = node;
}}
aria-hidden
className="pointer-events-none invisible fixed top-0 left-0 w-px"
style={{ height: `100${unit}` }}
/>
))}
<output className="fixed top-[env(safe-area-inset-top)] left-2 z-50 rounded-md bg-foreground px-2 py-1 font-mono text-xs text-background">
{text}
</output>
</>
);
}

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.

Components in this post

  • Sheet

    A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch.

    Free
  • Drawer

    The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free
  • App Shell

    The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones.

    Pro
  • useKeyboardInset

    How much of the screen the phone's on-screen keyboard covers, as a motion value, so bars pinned to the bottom can ride above it.

    Pro

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

React Bottom Sheet for the Web: Snap Points, Drag, Keyboard

Build a React bottom sheet for the web on vaul: snap points, drag to dismiss, nested sheets, the on-screen keyboard and the home indicator, with working code.

SRSerban Rusu·Oct 9, 2026·9 min read
Mobile UI in React

Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

Build a shadcn responsive dialog once: a centered card above 768px, a draggable bottom sheet below it, one copy of the content and no hydration flash.

SRSerban Rusu·Oct 9, 2026·10 min read
Next.js and Tailwind CSS

React Use Mobile Hook: SSR-Safe, No Hydration Mismatch

Why a React use mobile hook is false on the server, when CSS breakpoints beat it, and an SSR-safe useSyncExternalStore version with no hydration mismatch.

SRSerban Rusu·Oct 9, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI