# The phone's platform layer

> Make a web app feel installed on a phone with the right viewport tag, dvh and svh, safe areas, keyboard handling and overscroll, and know why each one exists.

Module: Layout and pages · Level: intermediate · 7 min · https://school.agents.org.in/design/layout/mobile-layer

Here's a web app on a phone, built edge to edge. Watch the title and the tab bar.

*[Interactive demo: Without insets the title hides under the Dynamic Island and the tabs sit on the home indicator. One line of CSS pads them back.]*

Most "it feels like a website on my phone" complaints aren't design problems. They're a handful of platform defaults (the address bar, the notch, the keyboard, the rubber-band bounce), each fixed by one declaration. Emil Kowalski's rule: reach for a media query or meta tag before JavaScript, and know the reason for every line.

## The baseline, before any component

```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#ffffff" />
```

```css
html { -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
```

In Next.js these live in the root layout's `viewport` export. This course's own layout sets exactly these.

> **Never block zoom.** `user-scalable=no` and `maximum-scale=1` take zoom away from everyone who needs it. If an input zooms the page on tap, the input is under 16px: fix the input.

## Safe areas

`viewport-fit=cover` in that viewport tag (The <meta name="viewport"> line that tells a phone how to size the page. viewport-fit=cover lets the page paint under the notch so safe-area insets work) lets your page paint under the notch and the home indicator. Then `env(safe-area-inset-*)` tells CSS how big each covered edge is, so you can pad content back into the safe area (The part of a phone screen not covered by the notch or Dynamic Island, the rounded corners or the home indicator. env(safe-area-inset-*) tells CSS how big those edges are).

```css
.header  { padding-top: env(safe-area-inset-top); }
.tabbar  { padding-bottom: env(safe-area-inset-bottom); }
.fab     { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
```

Headers take the top, tab bars and sheets take the bottom, sidebars take left and right in landscape. **Without `viewport-fit=cover`, every inset is 0px**, which is why "I added safe-area padding and nothing happened" is so common.

## 100vh lies on phones

`100vh` is the screen height with the address bar *hidden*. On load the bar is showing, so a 100vh panel runs past the bottom, and its button is out of sight. Toggle the bar and the units below.

*[Interactive demo: vh assumes the bar is gone; dvh follows it; svh is the small, steady height.]*

The dynamic units (Viewport height units that understand phone browser bars. 100dvh changes as the address bar shows and hides; 100svh is the smaller, steady height. Plain 100vh assumes the bar is hidden):

- **`100dvh`** follows the bar as it shows and hides. Use it for app shells and anything pinned to the bottom.
- **`min-height: 100svh`** is the small, steady height. Use it for a hero, because a `dvh` hero re-lays-out mid-scroll and jumps.

Tailwind's `h-screen` carries the old bug; use `h-dvh` and `min-h-svh`. *(dvh everywhere is the overcorrection. Shells track the browser bar; first screens don't.)*

## The keyboard

iPhone shrinks the visible area when the keyboard opens; Android Chrome lays the keyboard over the page. `interactive-widget=resizes-content` makes Android resize too, so a chat composer at the bottom of a `100dvh` column rides up above the keyboard with no script.

Two more rules: a fixed bottom button hides the last form field, so pad the scroll area by the button's height plus the bottom inset; and never autofocus an input on a touch device, which throws the keyboard up uninvited.

## Scrolling and bounce

- **`overscroll-behavior` (CSS that controls what happens when you scroll past the end: the bounce, pull-to-refresh, or the page behind scrolling instead. contain keeps it inside a panel): `none` on the root** stops pull-to-refresh and the page bounce inside an app. Leave it off on a document where pull-to-refresh is welcome.
- **`contain` on inner scrollers** keeps their own bounce without dragging the page along.
- **Scroll lock behind a sheet** goes through a dialog primitive (Base UI, Radix, vaul). `overflow: hidden` on `body` is ignored by iPhone touch scrolling.
- **Never `touchmove` + `preventDefault()`**: it blocks scrolling and makes the listener slow.
- **Anchor links** clear a sticky header with `scroll-margin-top` equal to its height.

## The status bar

One `theme-color` per color scheme, matched to the top of your page (the header), not your brand color. If the theme can be toggled, update the tag too. An installed web app's manifest colors are the same decision.

## Prove it on a real phone

Emulators reproduce none of this. Serve your dev server on your network, open it on a phone by its LAN address, and debug through Safari's Develop menu (iPhone) or `chrome://inspect` (Android). Use a phone a few years old, open the keyboard, turn it to landscape once, and install it as a web app if you ship one.

**Phone platform pass (paste into your agent):**

```text
Make this web app feel native on phones:
- Viewport: width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content. No user-scalable=no or maximum-scale.
- Safe areas: header padding-top env(safe-area-inset-top), tab bar/sheets padding-bottom env(safe-area-inset-bottom, 0px), sidebars left/right in landscape.
- Heights: app shell 100dvh, hero min-height 100svh, never 100vh (h-dvh / min-h-svh in Tailwind).
- overscroll-behavior: none on html for the app, contain on inner scrollers; scroll lock through our dialog primitive.
- One theme-color per scheme matched to the header. Inputs 16px+. No autofocus on touch.
Tell me what to check on a real device.
```

**Check yourself:** You added padding-bottom: env(safe-area-inset-bottom) to the tab bar, but it still sits on the home indicator. What's missing?

- [ ] A bigger padding value
- [x] viewport-fit=cover in the viewport meta tag
- [ ] position: fixed
- [ ] A media query for iPhone

*Why:* Without viewport-fit=cover the page doesn't extend under the system areas, so every safe-area inset resolves to 0px.

## In the skill

- https://school.agents.org.in/skill/layout/mobile-platform-layer.md
