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

> The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind

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

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](https://developer.mozilla.org/en-US/docs/Web/CSS/length) 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](https://developer.chrome.com/blog/url-bar-resizing) 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:

| Unit | dvh | svh | lvh |
| --- | --- | --- | --- |
| Measures | The visible height right now | The height with the toolbars expanded | The height with the toolbars retracted, same as vh |
| Changes while the page scrolls | Yes, as the toolbars move | No | No |
| Tailwind classes | `h-dvh`, `min-h-dvh`, `max-h-dvh` | `h-svh`, `min-h-svh`, `max-h-svh` | `h-lvh`, `min-h-lvh`, `max-h-lvh` |
| Use it for | Shells, sheets, anything that stays on screen | Heroes and first screens that scroll away | Fixed backgrounds that must never show a gap |

The dynamic unit has two catches. Browsers throttle it: [web.dev's introduction to the units](https://web.dev/blog/viewport-units) says the values "do not update at 60fps." And everything sized with it reflows when it does update. [WebKit bug 266835](https://bugs.webkit.org/show_bug.cgi?id=266835), 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](https://caniuse.com/viewport-unit-variants) counts about 95% of global usage as supported as of October 2026. The Tailwind dvh, svh and lvh classes [arrived in v3.4](https://tailwindcss.com/blog/tailwindcss-v3-4), and Tailwind v4 itself [requires](https://tailwindcss.com/docs/compatibility) 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](https://wingo-ui.com/blog/tailwind-safe-area-insets) 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](https://bugs.webkit.org/show_bug.cgi?id=228278) and [231878](https://bugs.webkit.org/show_bug.cgi?id=231878), 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) builds that layout on vaul with drag and focus handling.

The free [Drawer](https://wingo-ui.com/components/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>
  );
}
```

> Live demo (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. Try it at [Drawer](https://wingo-ui.com/components/drawer) and install it with `npx wingo-ui@latest add drawer`.

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.

> Live demo (Sheet): 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. Try it at [Sheet](https://wingo-ui.com/components/sheet) and install it with `npx wingo-ui@latest add sheet`.

## Does dvh change when the on-screen keyboard opens?

No. Safari on iOS, [Chrome for Android since version 108](https://developer.chrome.com/blog/viewport-resize-behavior) and [Firefox for Android since version 132](https://www.firefox.com/firefox/android/132.0/releasenotes/) 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](https://caniuse.com/mdn-html_elements_meta_name_viewport_interactive-widget) 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](https://wingo-ui.com/components/use-keyboard-inset) 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](https://wingo-ui.com/blog/react-ai-chat-ui).

> Live demo (useKeyboardInset): 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. Try it at [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) and install it with `npx wingo-ui@latest add use-keyboard-inset`.

## 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](https://wingo-ui.com/pricing). For the other phone rules, from bottom sheets to touch targets, read the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components) or browse [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## Components in this post

- [Sheet](https://wingo-ui.com/components/sheet) (Free): 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. Install: `npx wingo-ui@latest add sheet`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`
- [App Shell](https://wingo-ui.com/components/app-shell) (Pro): 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. Install: `npx wingo-ui@latest add app-shell`
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) (Pro): 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. Install: `npx wingo-ui@latest add use-keyboard-inset`

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

---

Source: https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind
