Skip to content

Mobile Web Browser Defaults

A UX principle for coding agents. Also covers mobile browser, mobile safari, ios safari, chrome android, viewport height, dvh, and 15 more.

Show all 21 aliases

mobile browser, mobile safari, ios safari, chrome android, viewport height, dvh, svh, lvh, 100vh, address bar, browser chrome, notch, safe area, viewport-fit, theme-color, status bar, tap highlight, pull to refresh, overscroll, webview, pwa

A layout that is correct in a desktop browser can still be wrong on a phone, because the mobile browser changes the rules the layout was built on. The address bar grows and shrinks under it, the notch covers its top corner, a tap leaves a gray flash and a stuck hover state, and a swipe that was meant for a carousel reloads the page instead.

These are not design mistakes. They are browser defaults, and each one has a specific fix. This doc is the list of those fixes, in problem order.

The sibling docs own the reasoning: responsive.md covers breakpoints and reflow, mobile-patterns.md covers sheets and layered composition, gestures.md covers custom drag and swipe, interactions.md covers touch and hover states. This doc covers only what the browser itself does to you.

A 100vh Layout Is Taller Than the Screen

On a phone, 100vh is not the height you can see. Chrome, Safari and Edge size vh against the viewport with the toolbars retracted, so a 100vh element is taller than the visible area whenever the address bar is showing. The bottom of it sits under the browser chrome, which is why the last button in a full-height layout is so often unreachable. Firefox is the exception and makes vh bimodal, which is a different bug, not a reprieve.

Three unit families fix this, and they are not interchangeable:

UnitHeight it usesBehavior
svhToolbars expanded (smallest)Fixed. Never changes as you scroll
lvhToolbars retracted (largest)Fixed. What vh already gives you
dvhWhatever is visible right nowLive. Changes as the toolbars move

Use 100dvh for an app shell that must fill the screen exactly. A chat view, a map, a canvas: something with fixed chrome and no page scroll of its own. It tracks the visible area, so nothing hides under the toolbar at any moment.

Use 100svh for a hero or any section inside a scrolling page. dvh resizes while the user scrolls, so a 100dvh hero grows mid-scroll and shoves the content below it down under the reader's thumb. svh is a fixed number, so the section stays still. It leaves a strip of the next section visible once the toolbar retracts, which is usually a benefit: it signals there is more page.

/* App shell: fills the visible area, tracks the toolbars */
.app { height: 100dvh; display: grid; grid-template-rows: auto 1fr auto; }

/* Hero in a scrolling page: fixed height, never resizes under the reader */
.hero { min-height: 100svh; }

Three caveats before you rely on these units:

  • dvh is throttled, not animated. Browsers update it in steps as the toolbars move, and some hold the old value until the scroll stops. Do not build an animation on it, and do not expect a smooth resize.
  • No viewport unit responds to the on-screen keyboard by default. A 100dvh app shell keeps its full height when the keyboard opens, so its bottom bar ends up behind the keyboard. Assuming dvh covers this is the common bug. Read the real numbers from window.visualViewport, which reports the keyboard on both engines, and adjust from its resize and scroll events.
  • interactive-widget=resizes-content changes that, on some engines. Adding it to the viewport meta shrinks the layout viewport when the keyboard opens, so the viewport units shrink with it. Chrome 108+ and Firefox for Android support it and Safari does not, so treat it as an enhancement layered on the visualViewport fix rather than a replacement for it. Next.js exposes it as interactiveWidget in the same viewport export.

Prefer min-height over height for anything holding text. A fixed height clips content when the user enlarges their font, which fails WCAG 1.4.4 (see responsive.md, Don't Break Zoom & Reflow).

Safe-Area Insets Are Zero Until You Opt In

env(safe-area-inset-bottom) resolves to 0px on every page that has not set viewport-fit=cover. So the padding you added for the notch and the home indicator does nothing, the CSS is valid, no error appears, and the layout looks unchanged. This is the most common reason safe-area padding "does not work".

The meta tag is the prerequisite:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

viewport-fit=cover tells the browser to lay the page out edge to edge, underneath the notch and the home indicator, and hand you the inset sizes so you can move your own content back out of the way. It is a trade: you gain the full screen and you take on the job of padding every fixed control. Once it is set, audit anything pinned to a viewport edge, since those elements are now genuinely under system chrome.

For which elements need which inset, and the bleed-versus-float rule, see ui/layout-spacing.md, Safe-Area Insets.

In a Next.js app the meta tag comes from the viewport export rather than hand-written markup:

// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
};

The Browser Chrome Takes Its Color From the Page

The address bar and the status bar above your page do not inherit anything from it by default. A dark app therefore sits under a white bar, and the seam makes the app look like a web page in a browser rather than an app.

Two different mechanisms close that seam, and you need both, because the browsers split on which one they use.

Android reads theme-color. Chrome, Edge and Samsung Internet tint their chrome from the meta tag. Declare one per color scheme, since the browser picks the first tag whose media query matches:

<!-- A literal color, kept in step with your own background token by hand.
     A meta tag cannot read a custom property, so var() is not an option. -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />

Current Safari ignores the tag and reads your page instead. Safari honoured theme-color from iOS 15 to 18.7 and dropped it in iOS 26. It now derives the toolbar tint from the page's own background color, or from a position: fixed or sticky element touching the top edge of the viewport. So on current iOS the lever is not the meta tag at all: it is making the real background behind the top of the page the color you want the bar to be. That is worth doing anyway, since it is the same answer both mechanisms want.

Two details that catch people out:

  • Keep the value a plain hex literal. A meta tag is not a styled element, so it cannot read a token, and engine support for modern color functions in this attribute is inconsistent. Hex is the format every browser that supports the tag parses. This is the same trade as transactional email: the value is a hand-maintained mirror of a token, so name the token it mirrors in a comment next to it.
  • An unparseable tag is skipped, not fatal. The browser moves on to the next theme-color tag, which is why fallbacks are written newest-syntax first.

Pair either mechanism with color-scheme, so the browser's own surfaces, form controls, and scrollbars follow too:

:root { color-scheme: light dark; }

Hover Sticks to the Last Thing Tapped

A touch browser fires hover on tap and then leaves it applied until the user taps something else, so the last card tapped stays lit behind them. This is the part that surprises people: it is not a flash, it is a state that persists.

The fix is to gate hover on input capability rather than screen width, and let :active carry the press feedback instead. For the media query and the rule that hover must never be the only way to reach something, see interactions.md, Hover States on Touch Devices, and ui/accessibility.md, Touch Devices.

A Tap Flashes a Gray Box

Mobile browsers paint a translucent gray or blue rectangle over anything tapped. It follows the element's box, not its visible shape, so it flashes square corners over a rounded button and extends past the label of an inline link.

Replace it with -webkit-tap-highlight-color rather than deleting it, since on an element with no :active state it is the only press feedback there is. See interactions.md, Custom Tap Highlight.

A Long Press Selects the Button Label

Press and hold a control on a phone and the browser starts a text selection, showing handles and a copy menu over your button. It reads as a glitch, and on a drag handle it hijacks the gesture completely.

Turn selection off on controls, and only on controls:

button, [role="button"], .drag-handle {
  user-select: none;
  -webkit-user-select: none; /* Safari still needs the prefix */
}

Do not put this on a container that holds real text. Users copy addresses, order numbers, and error codes out of interfaces, and a blanket user-select: none takes that away. For selection during a drag, see gestures.md, Contain the Gesture.

A Tap Feels Slow Because the Feedback Waits

The old answer here was the 300ms double-tap delay, and that is mostly history: both engines dispatch taps immediately on a page that sets width=device-width while the content is at scale 1. Set touch-action: manipulation on controls as a safety net for the zoomed-in case (see interactions.md, Prevent Double-Tap Zoom), then look for the real cause.

Today the lag is usually your own feedback. A state change that lands on click arrives after the finger lifts, and the user judges the response from the moment they pressed, not the moment they released. Show the pressed state on pointer-down and keep the real work on click, so a drag away from the control can still cancel it. See ui/polish.md, Native Feel Is Behavior First, and ui/perceived-speed.md, Acknowledge Input Instantly.

Pull-to-Refresh Fires Mid-Scroll

When a scroll container is already at the top, the next downward swipe goes to the browser, which reloads the page. In an app shell this is destructive: it throws away unsaved state on a gesture the user aimed at your content.

/* On the element that actually scrolls */
.scroller { overscroll-behavior: contain; }

Both values stop the browser navigation gesture at that boundary, including pull-to-refresh and swipe-back. The difference is the local affordance: contain keeps the bounce and glow at the end of the scroll, and none suppresses those too. Reach for contain unless the bounce itself is the problem.

Three things to check before you trust it:

  • It only applies to scroll containers. Putting it on html, body does nothing when those elements do not scroll, which is exactly the case in the app shell above. Put it on the element that owns the scrollbar.
  • iOS Safari support starts at 16.0, and there is an open WebKit report of it failing to suppress pull-to-refresh, so verify on hardware rather than assuming.
  • Weigh whether you should. Pull-to-refresh is a real affordance on content users expect to refresh, such as a feed. Remove it where a reload would lose work, not everywhere by reflex.

For its use inside dialogs and drawers, see interactions.md, Overscroll Behavior, and dialogs.md, Scroll & Overscroll.

A swipe up that starts on a horizontal carousel should still scroll the page, and a pinch on it should still zoom. Claim only the axis you need:

.carousel { touch-action: pan-y pinch-zoom; } /* you take horizontal, the user keeps the rest */

Bare pan-y also disables pinch-zoom on that element. On a full-width carousel that blocks zooming across most of the screen, which is the same accessibility failure as user-scalable=no. Keep pinch-zoom in the list unless the surface genuinely owns zooming, such as a map or a drawing canvas. For the full value table and pointer-capture handling, see gestures.md, Declare touch-action for Custom Touch Gestures.

Focusing an Input Zooms the Page

iOS Safari zooms the whole page in when the user focuses an input whose text is smaller than 16px, and leaves it zoomed after the keyboard closes. Render inputs at 16px on mobile and drop to a smaller size from the sm breakpoint up. Do not reach for maximum-scale=1 or user-scalable=no: they stop the zoom by blocking pinch-zoom for everyone, including the users who need it. See ui/typography.md, 16px Inputs on Mobile.

Device Mode Does Not Reproduce Any of This

Chrome's device toolbar resizes the viewport and changes the user agent string. It does not give you a moving address bar, a notch, a home indicator, browser chrome to tint, iOS Safari's rendering engine, or a real finger. Every problem in this doc is invisible in it.

So verify the mobile-specific ones on hardware, or at least in a real iOS Safari (see responsive.md, Testing Across Sizes):

  • Scroll far enough for the toolbars to retract, then back up, and watch for content that resizes or hides.
  • Rotate to landscape, where the notch moves to a side inset and the safe areas change.
  • Open the keyboard on the lowest input on the page and confirm it is still visible.
  • Check the status bar seam in both light and dark mode, on iOS as well as Android.
  • Tap and long-press every control, looking for the gray flash, a stuck hover, and a selection menu.

Fixes at a Glance

ProblemFix
Layout is taller than the screen100dvh for an app shell, 100svh for a hero
Bottom bar hides behind the keyboardRead window.visualViewport, not a viewport unit
Safe-area padding does nothingAdd viewport-fit=cover to the viewport meta
Status bar color does not matchtheme-color for Android, page background for iOS
Hover state sticks after a tapGate on @media (hover: hover) and (pointer: fine)
Gray flash on tap-webkit-tap-highlight-color plus a real :active state
Long press selects the labeluser-select: none on controls only
Tap feels slowFeedback on pointer-down, action on click
Pull-to-refresh hijacks a scrolloverscroll-behavior: contain on the real scroller
Carousel steals vertical scrolltouch-action: pan-y pinch-zoom
Focusing an input zooms the pageInput font size at least 16px
Correct in device mode, wrong on the phoneTest on real hardware

Checklist

Viewport and chrome

  • Full-height layouts use dvh or svh, never bare vh
  • min-height rather than height on anything holding text
  • Keyboard handled through window.visualViewport, not assumed from dvh
  • Viewport meta carries viewport-fit=cover if the page uses env(safe-area-inset-*)
  • Every control pinned to a viewport edge is padded off it
  • theme-color set per color scheme for Android, and the page background set correctly for iOS
  • color-scheme declared so browser surfaces follow

Touch behavior

  • Hover effects gated on (hover: hover) and (pointer: fine)
  • Tap highlight replaced with a real :active state, not just removed
  • user-select: none on controls and drag handles only, never on text
  • touch-action claims one axis and keeps pinch-zoom
  • Press feedback fires on pointer-down, action on click
  • overscroll-behavior set on the element that actually scrolls
  • Input font size at least 16px on mobile, and zoom never disabled

Verification

  • Checked on real hardware, not only in device mode
  • Toolbars retracted and re-expanded, keyboard opened, device rotated
  • Light and dark mode both checked, including the status bar seam

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ux-principle({ topic: "mobile-web" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems