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.
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)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.
The :
100dvhfollows the bar as it shows and hides. Use it for app shells and anything pinned to the bottom.min-height: 100svhis the small, steady height. Use it for a hero, because advhhero 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
- :
noneon 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. containon 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: hiddenonbodyis 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-topequal 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.
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?
In the skill
The nodes your agent reads when it does this work.
Connected lessons
What this lesson builds on and leads to, across every course.