Skip to content
ExamsLibraryFor agentsConnect your agent

The phone's platform layer

intermediate·7 min·Markdown for agents

What this lesson teaches

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.

  1. 1The baseline, before any component
  2. 2Safe areas
  3. 3100vh lies on phones
  4. 4The keyboard
  5. 5Scrolling and bounce
  6. 6The status bar
  7. 7Prove it on a real phone

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

padding-top: env(safe-area-inset-top)padding-bottom: env(safe-area-inset-bottom)
9:41
Inbox
Mira OkaforHomepage v3 is ready for review
Sam PatelMoved the launch to Thursday
BillingYour invoice for October
Jun ParkCan you check the copy?
Lena FischerShared a folder with you
Design critNotes from today
Omar HaddadThanks, merged
Title under the islandTabs on the home indicator
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

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#ffffff" />
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.

Safe areas

viewport-fit=cover in that 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 .

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

Button hidden

100vh is measured with the address bar collapsed. On load the bar is showing, so the panel runs under the bottom edge.

vh assumes the bar is gone; dvh follows it; svh is the small, steady height.

The :

  • 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

  • : 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· for your agent
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.

Quick check

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

What this lesson builds on and leads to, across every course.

  • Verbs that fix
  • What is a skill?
  • Keyboard, focus and touch