App-Like UX Patterns for Progressive Web Apps¶
An app-like PWA is a web app whose interaction model matches what users expect from installed software: instant feedback on touch, stable full-height layouts that respect notches and on-screen keyboards, navigation that works without a browser back button, and form fields that summon the right keyboard. None of this needs a framework. It comes from a small set of HTML attributes, CSS properties and browser APIs, each with platform quirks that decide whether your app feels native or feels like a web page in a frame. This page covers each pattern down to the property values, support data and production code.
Key takeaways
- Standalone mode removes the browser's back button, reload button and address bar. You own back navigation, refresh and sharing, and modals must close on the Android back gesture (use
<dialog>, popovers orCloseWatcherin Chromium, a history entry elsewhere). - Use
width=device-width, initial-scale=1, viewport-fit=coverand neveruser-scalable=noormaximum-scale=1. Fix input zoom on iOS with a 16 px font size instead. - Size touch targets to at least 24×24 CSS px (WCAG 2.2 AA) and preferably 44–48 px, use
touch-action: manipulationon controls, and give every control a visible:activestate. - Contain scrolling with
overscroll-behavior, pad fixed chrome withenv(safe-area-inset-*), and size full-height layouts withdvh/svhinstead ofvh. - Handle the on-screen keyboard with
interactive-widgetand thevisualViewportAPI everywhere, and the Chromium-only VirtualKeyboard API where you need full control. - Summon the right keyboard with
type,inputmode,enterkeyhintandautocomplete, disable text selection only on UI chrome, and use system fonts andcolor-schemefor a native look in light and dark mode.
What "app-like" means in practice¶
"App-like" is not a visual style. It's a set of expectations users bring from native apps, most of which a browser tab satisfies for you and a standalone window doesn't. When your PWA runs in standalone or fullscreen display mode, the following become your responsibility:
| Expectation | In a browser tab | In a standalone window | What you build |
|---|---|---|---|
| Going back | Browser back button, swipe gestures | Android system back only; nothing on iOS or desktop | In-app back affordance, history-aware modals |
| Refreshing stale data | Reload button, pull-to-refresh | Pull-to-refresh on Chrome for Android only | Refresh control, update prompts |
| Knowing where you are | Address bar | Nothing | Clear titles, breadcrumbs, document.title |
| Sharing the current screen | Browser share menu | Nothing on mobile | Share button with navigator.share() |
| Immediate touch feedback | Tap highlight (sometimes) | Same, but expected to feel native | :active styles, no double-tap delay |
| Stable layout | Toolbars shrink and grow | Status bar, notch, home indicator, keyboard | Safe-area padding, viewport units, keyboard handling |
| Native look | Page styles | Page styles next to OS chrome | System fonts, theme color, dark mode |
Four principles guide every pattern on this page:
- Respond within one frame of input. A tap that doesn't visibly register within about 100 ms feels broken. Press states and optimistic UI matter more than raw load speed once the app is running. Runtime Performance covers the INP side of this.
- Never fight the platform. Don't disable zoom, don't hijack system gestures, don't reimplement scrolling. Constrain browser behavior only where it collides with your UI (pull-to-refresh inside a chat, scroll chaining out of a bottom sheet).
- Degrade to a good website. Every pattern here must also work in a browser tab, where the user has browser chrome. Detect standalone mode and add app chrome only there.
- Use the platform's primitives.
<dialog>, popovers,inputmode,autocompleteandcolor-schemegive you native behavior (keyboard handling, autofill, accessibility, back-gesture integration) that custom widgets must reimplement.
The viewport meta tag, done right¶
Everything else on this page builds on a correct viewport meta tag. It's a comma-separated list of key=value directives:
| Directive | Values | Default | Use in a PWA |
|---|---|---|---|
width | device-width or 1–10000 | Browser-specific (often 980) | Always device-width |
initial-scale | 0.0–10.0 | Browser-specific | 1 |
minimum-scale, maximum-scale | 0.0–10.0 | – | Don't set |
user-scalable | yes, no | yes | Don't set |
viewport-fit | auto, contain, cover | auto | cover if you draw edge to edge and pad with safe-area insets |
interactive-widget | resizes-visual, resizes-content, overlays-content | resizes-visual | Set only if you need a non-default keyboard behavior (see the on-screen keyboard) |
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
width=device-width does more than set the layout width. It is also what lets mobile browsers dispatch taps without waiting to see whether a second tap follows: WebKit's More Responsive Tapping on iOS describes fast tapping for pages with width=device-width at the initial zoom level, and Chromium applies the same optimization.
Never block zoom¶
user-scalable=no and maximum-scale=1 are the most common "make it feel like an app" mistake. They prevent people with low vision from enlarging text and UI, and they fail WCAG success criterion 1.4.4 (Resize Text). MDN's viewport reference is blunt: "Disabling zooming capabilities by setting user-scalable to a value of no prevents people experiencing low vision conditions from being able to read and understand page content."
Browsers already defend users against it in part. Safari on iOS has ignored user-scalable=no, minimum-scale and maximum-scale for pinch zoom since iOS 10, and Chrome for Android has an accessibility setting that forces zoom. Everyone else, including Chrome users who never found that setting, is locked out.
The usual reason for adding maximum-scale=1 is that Safari on iOS zooms the page when a text field with a font size below 16 px receives focus. Fix the cause instead:
/* Safari on iOS zooms into focused fields whose font size is below 16px.
16px (1rem at the default root size) prevents that without disabling zoom. */
input,
select,
textarea {
font-size: max(16px, 1rem);
}
If double-tap-to-zoom interferes with rapid taps on specific controls (a calculator keypad, a game button), remove it only on those elements with touch-action: manipulation, which keeps pinch zoom working. See touch-action.
Navigation patterns¶
Choosing between bottom navigation, a top bar and a rail¶
Native mobile apps converged on a small number of navigation structures, and users recognize them instantly:
| Pattern | Use when | Destinations | Where it lives |
|---|---|---|---|
| Bottom navigation (tab bar) | 3–5 top-level destinations of equal importance, frequent switching | 3–5 | Bottom edge on phones, within thumb reach |
| Top app bar with back button | Hierarchical drill-down (list → detail → edit) | Any | Top edge; title plus back and actions |
| Navigation drawer | Many destinations, infrequent switching | 5+ | Off-canvas; opened from a menu button |
| Navigation rail | Same destinations as bottom navigation, on tablets and desktops | 3–7 | Left edge on wide screens |
Most apps combine a bottom navigation (top level) with a top app bar (hierarchy). The bottom bar becomes a rail on wide screens. Responsive & Adaptive Design covers the breakpoint and container-query side of that switch; the markup and touch details live here.
<header class="app-bar">
<button class="app-bar__back" type="button" hidden aria-label="Back">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</button>
<h1 class="app-bar__title">Inbox</h1>
</header>
<main id="main" tabindex="-1">…</main>
<nav class="tab-bar" aria-label="Primary">
<a href="/inbox" aria-current="page">
<svg aria-hidden="true" width="24" height="24"><use href="#icon-inbox"/></svg>
<span>Inbox</span>
</a>
<a href="/search">
<svg aria-hidden="true" width="24" height="24"><use href="#icon-search"/></svg>
<span>Search</span>
</a>
<a href="/settings">
<svg aria-hidden="true" width="24" height="24"><use href="#icon-settings"/></svg>
<span>Settings</span>
</a>
</nav>
:root {
--tab-bar-h: 56px;
--app-bar-h: 56px;
}
.app-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
/* Extend the background under a translucent status bar / notch,
keep the content below it. */
padding-top: env(safe-area-inset-top, 0px);
padding-inline: max(8px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px));
min-height: calc(var(--app-bar-h) + env(safe-area-inset-top, 0px));
background: var(--surface);
}
.app-bar__back {
inline-size: 48px;
block-size: 48px;
display: grid;
place-items: center;
}
.tab-bar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 10;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
/* Keep labels above the home indicator / gesture bar. */
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
background: var(--surface);
border-top: 1px solid var(--outline);
}
.tab-bar a {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: var(--tab-bar-h); /* comfortably above 44–48px targets */
font-size: 0.75rem;
color: var(--on-surface-muted);
text-decoration: none;
-webkit-user-select: none; /* Safari still needs the prefix */
user-select: none;
}
.tab-bar a[aria-current="page"] {
color: var(--primary);
font-weight: 600;
}
/* Leave room for the fixed bar so the last item isn't hidden. */
main {
padding-bottom: calc(var(--tab-bar-h) + env(safe-area-inset-bottom, 0px) + 16px);
}
/* Wide screens: the tab bar becomes a navigation rail. */
@media (min-width: 840px) {
.tab-bar {
inset-block: 0;
inset-inline: 0 auto;
width: 88px;
grid-auto-flow: row;
grid-auto-rows: 72px;
align-content: start;
padding-top: calc(var(--app-bar-h) + env(safe-area-inset-top, 0px));
border-top: 0;
border-inline-end: 1px solid var(--outline);
}
main {
padding-bottom: 16px;
padding-inline-start: 88px;
}
}
Mark the current destination with aria-current="page", not only a color, so assistive technology announces it. Tapping the active tab again should scroll that view to the top (a convention from iOS and Android that users try instinctively):
document.querySelector(".tab-bar").addEventListener("click", (event) => {
const link = event.target.closest("a");
if (link?.getAttribute("aria-current") === "page") {
event.preventDefault(); // don't reload the current view
window.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}
});
Back navigation in standalone mode¶
Whether users have a back button depends on the platform, not on your app:
| Platform, standalone mode | System back | What you need |
|---|---|---|
| Android (Chrome, Samsung Internet) | Back gesture or button walks your history, then leaves the app | History-aware modals; no in-app back button required, but a top-bar back arrow is conventional for drill-down |
| iOS and iPadOS Home Screen web app | None documented | Visible back button on every non-root screen |
Desktop Chromium (standalone) | Keyboard shortcut only (Alt+Left on Windows and Linux, Cmd+[ on macOS) | Visible back button |
Desktop Chromium (minimal-ui) | Back and reload buttons in the title bar | Hide your own copies |
Show an in-app back button only in app-like display modes and only when there's an in-app entry to return to. The Navigation API's navigation.canGoBack answers that precisely; it's available in Chrome 102, Firefox 147 and Safari 26.2. A complete, display-mode-aware back button module lives on Display Modes, and iOS & iPadOS covers the cold-launch case where a deep link has no history at all.
History management for modals, sheets and drawers¶
On Android, users press back to dismiss whatever is on top: a dialog, a bottom sheet, a drawer, a full-screen image viewer. If your overlay doesn't handle it, back navigates the page underneath, or leaves the app entirely. There are two mechanisms, and a robust app uses both.
Close requests (Chromium). Chrome introduced close requests in Chrome 120: the Esc key on desktop and the back gesture on Android are delivered first to the topmost open modal <dialog>, popover or CloseWatcher, and only then to history navigation. The feature was disabled after launch because of an interaction with <dialog> and re-enabled in Chrome 126. Firefox 149 ships CloseWatcher as well; Safari has it only in Technology Preview as of September 2026. The practical consequence is that a modal opened with dialog.showModal() or a popover element closes on Android back with zero code.
For custom overlays, CloseWatcher gives you the same integration:
| Member | Behavior |
|---|---|
new CloseWatcher({ signal }) | Creates an active watcher. The optional AbortSignal destroys it when aborted. Watchers created without user activation are grouped, so one close request closes the whole group (anti-abuse). |
requestClose() | Fires cancel (cancelable), then close unless canceled. Use it for your own close button. |
close() | Fires close without cancel. |
destroy() | Deactivates the watcher without firing events. Call it when the overlay closes by other means. |
cancel event | Lets you confirm before closing ("Discard draft?"). The browser only lets you prevent it when there has been user activation, so a page can't trap users. |
close event | Tear down your overlay here. |
History entries (everywhere). Browsers without close requests (Safari today) and Android browsers without them need a history entry per overlay: push a state when opening, and treat popstate as "close". This is the pattern apps used for a decade, and it has sharp edges: if the user closes the overlay with your close button, you must pop the entry you pushed, or the next back press does nothing visible.
The module below implements both. It prefers CloseWatcher, falls back to a history entry, and never leaves orphaned entries:
/**
* Make any overlay (sheet, drawer, lightbox) close on the platform's
* "close request": Esc, Android back, or browser back where CloseWatcher
* isn't available.
*
* Usage:
* const handle = trackOverlay({ onClose: () => sheet.hide() });
* closeButton.onclick = () => handle.requestClose();
* // when the overlay closes for another reason (e.g. navigation):
* handle.dispose();
*/
export function trackOverlay({ onClose, confirmClose } = {}) {
let closed = false;
const finish = () => {
if (closed) return;
closed = true;
onClose?.();
};
// 1) Native close requests: Chromium 126+, Firefox 149+.
if ("CloseWatcher" in window) {
const watcher = new CloseWatcher();
watcher.addEventListener("cancel", (event) => {
// Only honored when the page has user activation; otherwise the
// browser closes regardless, so never rely on it for data safety.
if (confirmClose && !confirmClose()) event.preventDefault();
});
watcher.addEventListener("close", finish);
return {
requestClose: () => watcher.requestClose(),
dispose: () => {
closed = true;
watcher.destroy();
},
};
}
// 2) Fallback: one history entry per overlay.
const marker = { overlay: crypto.randomUUID() };
history.pushState({ ...history.state, ...marker }, "");
const onPopState = () => {
// Back was pressed (or history.back() was called by requestClose).
window.removeEventListener("popstate", onPopState);
if (confirmClose && !closed && !confirmClose()) {
// Re-arm: put the entry back so the overlay stays "on top".
history.pushState({ ...history.state, ...marker }, "");
window.addEventListener("popstate", onPopState);
return;
}
finish();
};
window.addEventListener("popstate", onPopState);
return {
// Close via our own UI: pop the entry we pushed, which fires popstate.
requestClose: () => {
if (history.state?.overlay === marker.overlay) history.back();
else onPopState();
},
// Overlay closed because the app navigated elsewhere: drop the listener
// but leave history alone (the navigation replaced our entry's meaning).
dispose: () => {
closed = true;
window.removeEventListener("popstate", onPopState);
},
};
}
Two rules keep history sane:
- Don't push entries for transient UI such as tooltips, menus or toasts. Only overlays a user would "go back" from deserve one.
- Prefer native elements. A modal
<dialog>gets close requests, focus trapping, the top layer andinertbackground for free. Chrome 134 and Firefox 141 addclosedby="any"for light dismiss (clicking the backdrop) on dialogs; until Safari ships it, keep a backdrop click handler.
Client-side routing with the Navigation API¶
If your PWA is a single-page app, the Navigation API (Chrome 102, Firefox 147, Safari 26.2) replaces fragile pushState routers. Its navigate event fires for link clicks, form submissions, back/forward and navigation.navigate(), and event.intercept() turns any of them into a same-document transition while the browser keeps managing history, focus and scroll restoration:
// Minimal Navigation API router with a History API fallback left to the server.
if ("navigation" in window) {
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
// Leave cross-origin, download and non-interceptable navigations alone.
if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
if (url.origin !== location.origin) return;
const route = matchRoute(url.pathname); // your route table
if (!route) return; // let the browser do a full navigation (e.g. to /logout)
event.intercept({
// "after-transition" (default) restores scroll for traversals and
// scrolls to top/fragment for pushes once the handler settles.
scroll: "after-transition",
focusReset: "after-transition",
async handler() {
const view = await route.load({ url, signal: event.signal });
document.querySelector("#main").replaceChildren(view);
document.title = `${route.title} – Inbox`;
},
});
});
}
event.signal aborts when the user navigates again before the handler finishes, so a slow response can't overwrite a newer view. Pair the router with View Transitions for animated screen changes, and see SPA vs MPA PWAs for whether you need client-side routing at all.
Touch interaction¶
Touch target sizes¶
Fingers are imprecise. The standards and platform guidelines agree on minimums:
| Source | Minimum target | Notes |
|---|---|---|
| WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA | 24 × 24 CSS px | Smaller targets pass only with enough spacing, an equivalent control elsewhere, inline text links, or when the size is essential |
| WCAG 2.2 SC 2.5.5 Target Size (Enhanced), level AAA | 44 × 44 CSS px | Same exceptions, stricter size |
| Apple Human Interface Guidelines | 44 × 44 pt | 1 pt = 1 CSS px in Safari on iOS |
| Material Design | 48 × 48 dp | Visual icon can be 24 dp inside the target |
The visual size and the hit area don't need to match. Keep a 24 px icon and grow its hit area with padding, or with a pseudo-element when layout doesn't allow padding:
/* Grow the hit area without changing layout. */
.icon-button {
position: relative;
inline-size: 24px;
block-size: 24px;
}
.icon-button::before {
content: "";
position: absolute;
inset: 50% auto auto 50%;
inline-size: 48px;
block-size: 48px;
translate: -50% -50%;
}
/* Only coarse pointers need the big targets; keep dense layouts for mice. */
@media (pointer: fine) {
.icon-button::before {
inline-size: 32px;
block-size: 32px;
}
}
Use (pointer: coarse) and (any-pointer: coarse) to adapt density: the former describes the primary input, the latter matches if any input is coarse (a laptop with a touchscreen). Don't use viewport width as a proxy for touch.
touch-action: telling the browser which gestures you handle¶
The browser can't know whether your JavaScript wants a touch sequence until it has run your touchstart/pointerdown listeners, which would delay scrolling. touch-action declares it up front, so the compositor can scroll and zoom without waiting for the main thread:
| Value | Browser handles | You get pointer events for | Typical use |
|---|---|---|---|
auto | Everything | Taps only (pans are cancelled with pointercancel) | Default |
manipulation | Panning and pinch zoom; no double-tap zoom | Taps without double-tap delay | Buttons, keypads, links in dense UI |
pan-y | Vertical panning (plus pinch zoom if pinch-zoom added) | Horizontal drags | Horizontal carousels, swipe-to-dismiss rows |
pan-x | Horizontal panning | Vertical drags | Vertical sliders, pull-down gestures inside horizontal scrollers |
pinch-zoom | Pinch zoom (combine with pan-*) | Single-finger drags | Custom single-finger drawing on a zoomable page |
none | Nothing | All touches | Canvas drawing, maps, games |
pan-up, pan-down, pan-left, pan-right | One direction only | The other directions | Pull-to-refresh zones (Chromium only) |
touch-action is supported since Chrome 36, Firefox 52, Safari 13 on macOS and Safari 9.3 on iOS (where manipulation came first), and the directional pan-up/pan-down/pan-left/pan-right values are Chromium-only.
Two details trip people up:
- The effective value is the intersection of the element's value and every ancestor up to the nearest scroll container. You can't re-enable on a child what a parent disabled.
touch-action: noneon a large area is an accessibility and usability hazard. It disables page scrolling and pinch zoom for touches that start there. Scope it to the canvas that needs it.
Press feedback: :active, tap highlight and hover¶
Every tappable element needs a visible pressed state. Two platform quirks get in the way:
- Tap highlight. Chromium browsers and Safari on iOS paint a translucent overlay on tapped links and buttons, controlled by the non-standard
-webkit-tap-highlight-color(Firefox doesn't have it). It looks out of place on custom controls. Remove it only where you provide your own:activestyle. :activeon iOS. Apple's archived Safari Web Content Guide notes that on iOS "emulated mouse events are sent so quickly that the down or active pseudo state of buttons may never occur". The widely used fix is to register a touch listener, which makes WebKit track the touch and apply:activewhile the finger is down. A passive listener on the document costs nothing measurable and doesn't block scrolling.
button,
a,
[role="button"] {
-webkit-tap-highlight-color: transparent; /* we draw our own feedback */
transition: background-color 120ms ease-out, scale 120ms ease-out;
}
button:active,
a:active,
[role="button"]:active {
background-color: var(--pressed-overlay);
scale: 0.98;
}
/* Hover styles only where hover is real; on touchscreens :hover
"sticks" after a tap until the user taps elsewhere. */
@media (hover: hover) and (pointer: fine) {
button:hover,
a:hover {
background-color: var(--hover-overlay);
}
}
@media (prefers-reduced-motion: reduce) {
button,
a,
[role="button"] {
transition: none;
}
button:active,
a:active,
[role="button"]:active {
scale: none;
}
}
// Makes WebKit on iOS apply :active while a finger is down.
// Passive: never delays scrolling.
document.addEventListener("touchstart", () => {}, { passive: true });
Wrap hover effects in @media (hover: hover). MDN's compatibility notes record that some Samsung devices incorrectly match (hover: hover), so treat hover as a progressive enhancement, never as the only way to reveal a control.
Long-press callouts and context menus¶
A long press on a link or image in Safari on iOS opens a preview callout; in Chromium on Android it opens a context menu. Both are useful on content and disruptive on UI you've built for long-press (a draggable list item, a press-and-hold record button).
/* Only on elements that implement their own long-press behavior. */
.draggable-row,
.hold-to-record {
-webkit-touch-callout: none; /* Safari on iOS: no link/image preview */
-webkit-user-select: none;
user-select: none;
}
// Suppress the context menu (Android, desktop right-click) only on our widget.
recordButton.addEventListener("contextmenu", (event) => event.preventDefault());
Keep the defaults on content links and images: users rely on long-press to copy links, open in the browser and save images.
Custom gestures with Pointer Events¶
For swipe-to-dismiss, drag-to-reorder or a bottom sheet you can drag, use Pointer Events, which unify touch, pen and mouse. The ingredients are touch-action for the axis you own, setPointerCapture() so moves keep arriving when the finger leaves the element, and pointercancel handling for when the browser takes over (a vertical scroll that started on your horizontal swiper).
/**
* Horizontal swipe-to-dismiss for list rows.
* CSS: .row { touch-action: pan-y; } so vertical scrolling stays native.
*/
export function enableSwipeToDismiss(row, { onDismiss, threshold = 0.35 } = {}) {
let startX = 0;
let dx = 0;
let pointerId = null;
let startTime = 0;
const reset = () => {
row.style.transition = "translate 200ms ease-out";
row.style.translate = "0";
pointerId = null;
};
row.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0) return;
pointerId = event.pointerId;
startX = event.clientX;
dx = 0;
startTime = event.timeStamp;
row.setPointerCapture(pointerId); // keep receiving moves off-element
row.style.transition = "none";
});
row.addEventListener("pointermove", (event) => {
if (event.pointerId !== pointerId) return;
dx = event.clientX - startX;
row.style.translate = `${dx}px`;
});
row.addEventListener("pointerup", (event) => {
if (event.pointerId !== pointerId) return;
const width = row.getBoundingClientRect().width;
const velocity = Math.abs(dx) / Math.max(1, event.timeStamp - startTime); // px/ms
if (Math.abs(dx) > width * threshold || velocity > 0.6) {
row.style.transition = "translate 180ms ease-in, opacity 180ms ease-in";
row.style.translate = `${Math.sign(dx) * width}px`;
row.style.opacity = "0";
row.addEventListener("transitionend", () => onDismiss?.(row), { once: true });
pointerId = null;
} else {
reset();
}
});
// The browser took over (e.g. the user scrolled vertically): snap back.
row.addEventListener("pointercancel", reset);
}
Every gesture needs a non-gesture alternative (a delete button, a menu item). Gestures are invisible, and WCAG 2.5.1 (Pointer Gestures) and 2.5.7 (Dragging Movements) require single-pointer alternatives. Accessibility covers these criteria.
Scrolling and overscroll¶
overscroll-behavior: stop scroll chaining¶
When a scroll container reaches its end, further scrolling "chains" to the next scrollable ancestor, eventually the document, which then triggers pull-to-refresh, the rubber-band bounce, or Android's back-navigation swipe in some browsers. For a chat pane, a side drawer or a bottom sheet, that's never what you want.
| Value | Scroll chaining | Overscroll effect (glow, bounce) | Pull-to-refresh |
|---|---|---|---|
auto | Yes | Yes | Yes (on the document) |
contain | No | Yes, on this element | No |
none | No | No | No |
chain | Yes | No | Yes (chaining to the document still triggers it) |
The CSS Overscroll Behavior spec splits boundary behavior into non-local actions (scroll chaining, pull-to-refresh, swipe navigation) and local ones (the glow or rubber band on the element itself). contain blocks the non-local actions, none blocks both, and the newer chain value is the inverse of contain: it keeps chaining but removes the element's own overscroll effect. chain shipped in Chrome 150 and is in Firefox 157 (beta as of September 2026); Safari doesn't support it, so treat it as an enhancement.
overscroll-behavior-x and -y (and the logical -inline/-block forms) set each axis independently. Support: Chrome 63, Firefox 59, Safari 16 (macOS and iOS). For years the property had no effect on scroll containers without scrollable overflow (a short list inside a sheet, or an overflow: hidden panel, still chained). The spec now says such a container is always at its scroll boundary, so contain or none on it blocks chaining. MDN's compatibility data records that Chrome 144 and Firefox 150 implement that rule, and Safari still has the old behavior, so on iOS make a short sheet's content at least calc(100% + 1px) tall (which gives it scrollable overflow) if chaining out of it matters.
/* Inner panes: no chaining into the page behind them. */
.chat-log,
.bottom-sheet__content,
.drawer {
overflow-y: auto;
overscroll-behavior-y: contain;
}
/* Whole app: no browser pull-to-refresh or bounce, because we provide our
own refresh control. Set on both: Chromium historically read <body>,
the spec and Safari use the root element. */
html,
body {
overscroll-behavior-y: none;
}
Only disable document-level overscroll when you replace pull-to-refresh with something (below). In a browser tab, users expect it.
Pull-to-refresh: browser versus custom¶
| Environment | Built-in pull-to-refresh |
|---|---|
| Chrome for Android, tab | Yes |
| Chrome for Android, installed (standalone) | Yes. web.dev: "On some browsers, such as Chrome on Android, that behavior is also enabled on standalone PWAs" |
| Safari on iOS, tab | Yes |
| iOS/iPadOS Home Screen web app | No, and Apple doesn't document any system gesture in web apps |
| Desktop app windows | No (keyboard shortcut to reload only) |
Because the built-in gesture reloads the whole page, it's rarely what an app with client-side state wants: it discards in-memory state and re-runs your boot sequence. A custom pull-to-refresh that re-fetches data, works the same in every environment, and falls back to a visible button is better:
/**
* Custom pull-to-refresh for the document scroller.
* Requires: html, body { overscroll-behavior-y: none; }
* Markup: <div class="ptr" role="status" aria-live="polite"></div> at the top of <main>
*/
export function enablePullToRefresh({ indicator, onRefresh, threshold = 72, max = 120 }) {
let startY = 0;
let pulling = false;
let distance = 0;
let busy = false;
const setDistance = (d) => {
distance = d;
indicator.style.setProperty("--ptr-distance", `${d}px`);
indicator.dataset.state = d >= threshold ? "armed" : "pulling";
};
window.addEventListener(
"touchstart",
(event) => {
// Only start at the very top, with one finger, when idle.
if (busy || window.scrollY > 0 || event.touches.length !== 1) return;
startY = event.touches[0].clientY;
pulling = true;
},
{ passive: true },
);
window.addEventListener(
"touchmove",
(event) => {
if (!pulling) return;
const dy = event.touches[0].clientY - startY;
if (dy <= 0) {
pulling = false;
setDistance(0);
return;
}
// Rubber-band resistance: the further you pull, the less it moves.
setDistance(Math.min(max, dy * 0.5));
},
{ passive: true },
);
// The OS or browser can take the touch away (incoming call, system gesture,
// palm rejection). Treat that as "released below the threshold".
window.addEventListener("touchcancel", () => {
if (!pulling) return;
pulling = false;
setDistance(0);
delete indicator.dataset.state;
});
window.addEventListener("touchend", async () => {
if (!pulling) return;
pulling = false;
if (distance < threshold) {
setDistance(0);
return;
}
busy = true;
indicator.dataset.state = "refreshing";
indicator.textContent = "Refreshing…";
try {
await onRefresh();
indicator.textContent = "Updated";
} catch {
indicator.textContent = "Couldn't refresh. Check your connection.";
} finally {
busy = false;
setDistance(0);
delete indicator.dataset.state;
}
});
}
The listeners are passive, so they never block native scrolling; the visual pull comes from the --ptr-distance custom property driving a translate on the indicator. Always pair the gesture with a refresh button or menu item for keyboard, mouse and switch users.
Keep the document as the scroller¶
Build app layouts so the document scrolls, not a full-height <div> with overflow: auto. Browser features attach to the root scroller: the address bar that collapses on scroll in Chrome and Safari tabs, scroll restoration on back navigation, find-in-page, and, on iOS, fewer surprises with the keyboard and fixed elements. Fixed headers and tab bars (position: fixed or sticky) plus padding give the same "app shell" look without an inner scroller. Reserve inner scroll containers for genuinely independent panes (a message list next to a thread on a tablet) and give them overscroll-behavior: contain.
Safe areas and edge-to-edge layouts¶
Phones have rounded corners, notches or the Dynamic Island, and gesture bars. By default, browsers inset the layout viewport to keep content away from them (viewport-fit=auto), letterboxing your page with the background color in landscape. For an app look, draw edge to edge and inset only the content:
- Add
viewport-fit=coverto the viewport meta tag. - Pad fixed and edge-touching elements with
env(safe-area-inset-top | right | bottom | left).
The four env() variables are supported in Chrome 69, Firefox 65, Safari 11.1 and Safari on iOS 11.3 and later (iOS 11.0–11.2 shipped them under the since-removed constant() function, which you can ignore today). Outside a cutout or gesture-bar area they resolve to 0px, so they're safe to use everywhere. Always pass a fallback as the second argument for older engines (env(safe-area-inset-top, 0px)), and combine them with max() where you also want a minimum padding.
Edge to edge on Chrome for Android¶
Chrome 135 made Chrome for Android edge-to-edge: "the viewport is allowed to extend into Android's gesture navigation bar". Without viewport-fit=cover, Chrome keeps a "chin" at the bottom that slides away as the user scrolls. With cover, the viewport extends to the bottom edge on load and safe-area-inset-bottom changes dynamically as the chin moves. Chrome 135 also added safe-area-max-inset-* variables: the maximum an inset can reach. Chrome's edge-to-edge guide warns against animating layout through padding-bottom: env(safe-area-inset-bottom) and recommends reserving the maximum up front and moving the bar with bottom, a path Chrome optimizes:
:root {
/* Fallback 0px: only Chromium defines safe-area-max-inset-*; elsewhere the
regular inset is static, so the calc below reduces to bottom: 0. */
--safe-max-bottom: env(safe-area-max-inset-bottom, env(safe-area-inset-bottom, 0px));
--bottom-bar-h: 56px;
}
.tab-bar {
position: fixed;
inset-inline: 0;
height: calc(var(--bottom-bar-h) + var(--safe-max-bottom));
padding-bottom: var(--safe-max-bottom);
/* Slides down behind the chin as it collapses, without relayout. */
bottom: calc(env(safe-area-inset-bottom, 0px) - var(--safe-max-bottom));
}
main {
padding-bottom: calc(var(--bottom-bar-h) + var(--safe-max-bottom) + 16px);
}
Three details from the same guide matter in production:
- Chrome detects the anti-pattern. To avoid performance regressions, Chrome doesn't slide the chin away while scrolling when it detects bottom-anchored content padded with
safe-area-inset-bottom. Pages that use the anti-pattern keep a permanent chin. - The fallback value. The guide's own example uses
36pxas the fallback forsafe-area-max-inset-bottom, because Safari on iOS in its single-tab (compact) layout has a maximum bottom offset of 36 px in portrait. Theenv(safe-area-inset-bottom, 0px)fallback above is simpler and correct, but it lets padding track a moving inset in Safari tabs; use the 36 px value if that causes visible jank. - Large screens are excluded. The Chrome 135 change targets small-screen phones; Chrome on Android tablets and large foldables didn't draw edge-to-edge at launch, and the rollout used a Chrome variation. Test both form factors.
On iOS, the insets are static per orientation: top for the status bar and Dynamic Island in portrait, left and right for the sensor housing in landscape, bottom for the home indicator. In a Home Screen web app, apple-mobile-web-app-status-bar-style decides whether your content starts under the status bar at all; Splash Screens & Theming covers that, and iOS & iPadOS has device details.
Testing safe areas without a device
The iOS Simulator reproduces every notch, Dynamic Island and home indicator exactly, and Safari's Web Inspector attaches to it. Chrome DevTools device emulation doesn't simulate insets, so env(safe-area-inset-*) stays 0px there. Check landscape on a real or simulated notched phone: that's where missing left and right padding shows up.
Viewport units: vh, svh, lvh and dvh¶
100vh was meant to be "the height of the screen". On mobile, the visible area changes as the browser's toolbars collapse and expand while scrolling, so browsers made vh equal to the largest possible viewport, which puts the bottom of a 100vh layout under the toolbar on first load. CSS Values 4 added three explicit sets of units:
| Unit family | Measures | Changes while scrolling? | Use for |
|---|---|---|---|
svh, svw, svmin, svmax (small) | Viewport with all browser UI expanded | No | Content that must be fully visible on load: splash views, empty states, sign-in forms |
lvh, lvw, … (large) | Viewport with browser UI retracted | No | Backgrounds that must cover the screen at all times |
dvh, dvw, … (dynamic) | The current viewport | Yes | Full-height app shells that should track toolbar changes |
vh, vw | Same as large in current mobile browsers | No | Legacy fallback only |
Also available per axis: *vi and *vb for inline and block. All three families are supported in Chrome 108, Firefox 101 and Safari 15.4.
In a standalone PWA without browser toolbars, small, large and dynamic are usually equal, so the choice matters most in the browser tab where users first meet your app. Guidelines:
- Use
min-height: 100dvh(with a100vhfallback) for the app root so short screens fill the window and long ones scroll the document. - Don't size many elements in
dvhthat resize continuously during scroll: every toolbar movement relayouts them. Prefersvhfor elements that only need to fit. - With the default
interactive-widget=resizes-visual, the on-screen keyboard doesn't change any of these units. Keyboard handling is a separate problem (next section).
.app-root {
min-height: 100vh; /* fallback for engines without dvh */
min-height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
}
/* A sign-in view that must fit above the fold with toolbars expanded. */
.sign-in {
min-height: 100svh;
display: grid;
place-content: center;
}
Responsive & Adaptive Design covers container queries, foldables (env(viewport-segment-*)) and breakpoints; this page stays with height and the keyboard.
The on-screen keyboard¶
When a text field gets focus on a phone, the keyboard covers roughly half the screen. What happens to your layout depends on the browser and on three levels of control you have.
flowchart TD
A["Field focused, keyboard opens"] --> B{"interactive-widget value"}
B -- "resizes-visual (default)" --> C["Visual viewport shrinks; layout viewport, ICB and vh/dvh unchanged; fixed bottom bars stay under the keyboard"]
B -- "resizes-content" --> D["Layout viewport shrinks; dvh and the ICB shrink; fixed bottom bars move above the keyboard; page relayouts"]
B -- "overlays-content" --> E["Nothing resizes; keyboard covers content; use VirtualKeyboard geometry"] interactive-widget in the viewport meta tag¶
Chrome 108 changed Chrome for Android to resize only the visual viewport when the keyboard opens, matching Safari on iOS and Chrome on iOS, and added the interactive-widget key so pages can choose. Firefox for Android added the interactive-widget key in Firefox 133 with the same resizes-visual default. As of September 2026 no Safari release (including Safari 27) supports the key, so Safari on iOS always behaves like resizes-visual, and desktop browsers ignore it because they have no on-screen keyboard that resizes the page.
| Value | Visual viewport | Layout viewport, ICB, dvh | Fixed bottom elements | Good for |
|---|---|---|---|---|
resizes-visual (default) | Shrinks | Unchanged | Stay put, hidden under keyboard | Most pages; no reflow cost |
resizes-content | Shrinks | Shrink | Move above keyboard | Chat and composer UIs on Android that want a fixed input bar to ride the keyboard |
overlays-content | Unchanged | Unchanged | Hidden under keyboard | Apps that position everything themselves with the VirtualKeyboard API |
<!-- Chat screen: let Chromium and Firefox on Android resize the layout so the
fixed composer sits on the keyboard. Safari ignores the key today. -->
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
The visualViewport API: works everywhere¶
Because Safari only resizes the visual viewport, a cross-browser keyboard-aware layout reads window.visualViewport. The distance between the bottom of the layout viewport and the bottom of the visual viewport is the part the keyboard (or browser UI) covers:
/**
* Publishes the height covered by the on-screen keyboard as --keyboard-inset
* on <html>. Works in Safari, Chrome and Firefox (any interactive-widget).
*/
export function trackKeyboardInset() {
const vv = window.visualViewport;
if (!vv) return () => {};
let frame = 0;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
// Layout viewport height minus the bottom of the visual viewport.
// offsetTop accounts for iOS scrolling the visual viewport up.
const covered = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
// Ignore small differences from pinch zoom and toolbar movement.
const inset = covered > 80 ? Math.round(covered) : 0;
document.documentElement.style.setProperty("--keyboard-inset", `${inset}px`);
document.documentElement.toggleAttribute("data-keyboard-open", inset > 0);
});
};
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
update();
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
cancelAnimationFrame(frame);
};
}
.composer {
position: fixed;
inset-inline: 0;
/* Sit on top of the keyboard when it's open, above the home indicator otherwise. */
bottom: var(--keyboard-inset, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
html[data-keyboard-open] .composer {
padding-bottom: 0; /* the keyboard already covers the home indicator area */
}
html[data-keyboard-open] .tab-bar {
display: none; /* native apps hide tab bars while typing */
}
The 80 px threshold is a heuristic that separates the keyboard from toolbar movement and zoom. Test on devices with hardware keyboards and on iPads with floating keyboards, where the covered area is small or zero.
The VirtualKeyboard API (Chromium)¶
The VirtualKeyboard API gives Chromium apps exact keyboard geometry and control over when the keyboard appears. It shipped in Chrome and Edge 94 on desktop and Android; Firefox and Safari don't implement it, it's restricted to secure contexts, and MDN labels it experimental.
Chromium-only
navigator.virtualKeyboard, the keyboard-inset-* environment variables and the virtualkeyboardpolicy attribute only exist in Chromium-based browsers. Feature-detect and keep the visualViewport path for Safari and Firefox.
| Member | Type | Behavior |
|---|---|---|
overlaysContent | boolean, settable | true makes the browser "leave its layout and visual viewports unchanged" when the keyboard shows (like interactive-widget=overlays-content). Required for the geometry to be useful. |
boundingRect | DOMRect | Keyboard rectangle in CSS pixels relative to the viewport; all zeros when hidden. |
show() | method | Shows the keyboard. Per the spec it only works with sticky user activation and when a form control or contenteditable element is focused whose virtualkeyboardpolicy is manual (for contenteditable) and whose inputmode isn't none. |
hide() | method | Hides the keyboard without blurring the field. The spec applies the same preconditions as show(): sticky activation, and a focused element whose virtualkeyboardpolicy is manual and whose inputmode isn't none. |
geometrychange | event | Fires when the keyboard's intersection with the viewport changes. Read navigator.virtualKeyboard.boundingRect. |
env(keyboard-inset-top \| right \| bottom \| left \| width \| height) | CSS | Keyboard geometry in CSS, 0px when hidden or when overlaysContent is false. |
virtualkeyboardpolicy="auto \| manual" | HTML attribute on contenteditable | manual stops the keyboard from appearing automatically on focus. |
// Progressive enhancement: take over keyboard layout in Chromium,
// fall back to visualViewport elsewhere.
import { trackKeyboardInset } from "./keyboard-inset.js";
if ("virtualKeyboard" in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
navigator.virtualKeyboard.addEventListener("geometrychange", () => {
const { height } = navigator.virtualKeyboard.boundingRect;
document.documentElement.toggleAttribute("data-keyboard-open", height > 0);
});
} else {
trackKeyboardInset();
}
/* keyboard-inset-height is 0px when the keyboard is hidden or unsupported;
--keyboard-inset comes from the visualViewport fallback. */
.composer {
bottom: max(env(keyboard-inset-height, 0px), var(--keyboard-inset, 0px));
}
Two uses stand out: editors that keep a formatting toolbar pinned directly above the keyboard, and "tap to edit" surfaces (contenteditable virtualkeyboardpolicy="manual") where a double-tap, not focus, should summon the keyboard.
Form input: inputmode, enterkeyhint and autocomplete¶
The keyboard your field summons is the difference between a native-feeling form and a frustrating one. Four attributes control it.
type sets semantics and validation (email, tel, url, number, search, date, password). Choose it first.
inputmode picks the virtual keyboard without changing semantics or validation. Supported in Chrome 66, Firefox 95 (Firefox for Android 79) and Safari 12.1 (iOS 12.2):
inputmode | Keyboard | Use for |
|---|---|---|
none | No virtual keyboard | Fields with a custom on-screen picker |
text | Standard, locale-specific | Default |
decimal | Digits and the locale's decimal separator | Amounts, measurements |
numeric | Digits only | PINs, one-time codes, card numbers, postal codes (as type="text") |
tel | Telephone keypad (*, #, +) | Phone numbers |
search | Keyboard optimized for search | Search boxes |
email | @ and . prominent | Email addresses |
url | / and . prominent | URLs |
Use type="text" inputmode="numeric" rather than type="number" for identifiers such as card numbers and codes: type="number" strips leading zeros, adds spinner controls and changes the value on scroll-wheel on desktop.
enterkeyhint labels the Enter key: enter, done, go, next, previous, search, send. Supported in Chrome 77, Firefox 94, Safari 13.1 and Safari on iOS 13.4. It only changes the label; you still implement the behavior (moving focus on next, submitting on send).
autocomplete lets the browser and OS fill fields from saved data and password managers. It's the single most effective form UX improvement. Useful tokens in PWAs:
| Token | Fills |
|---|---|
username, current-password, new-password | Credentials; new-password triggers strong password suggestions |
username webauthn | Username field that also offers passkeys via conditional mediation (Authentication & Passkeys) |
one-time-code | SMS and email verification codes from the OS suggestion bar |
email, tel, name, given-name, family-name | Contact details |
street-address, postal-code, country, address-level1, address-level2 | Addresses |
cc-name, cc-number, cc-exp, cc-csc | Payment cards |
Add autocapitalize (off, none, sentences, words, characters), spellcheck="false" and autocorrect="off" to fields such as usernames and codes where "helpful" corrections break input. autocapitalize works in Chromium (43+), Firefox (111+) and Safari on iOS; Safari on macOS doesn't implement it, and it only affects virtual keyboards and voice input. autocorrect started as a Safari-only attribute but is now a standard HTML global attribute: MDN's compatibility data lists Safari 14.1 (iOS 14.5), Firefox 136 and Chrome 153. Older Chromium ignores it, so keep spellcheck="false" as well.
<form id="verify" method="post" action="/verify">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username webauthn"
autocapitalize="none" spellcheck="false" enterkeyhint="next" required>
<label for="code">6-digit code</label>
<input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}"
maxlength="6" autocomplete="one-time-code" enterkeyhint="done" required>
<label for="amount">Amount</label>
<input id="amount" name="amount" type="text" inputmode="decimal" enterkeyhint="send">
<button type="submit">Verify</button>
</form>
// Implement what enterkeyhint="next" promises: move to the next field.
document.querySelector("#verify").addEventListener("keydown", (event) => {
const field = event.target;
if (event.key !== "Enter" || field.enterKeyHint !== "next") return;
event.preventDefault();
const fields = [...field.form.elements].filter((el) => el.willValidate && !el.disabled);
fields[fields.indexOf(field) + 1]?.focus();
});
Selective text selection with user-select¶
In a browser, long-pressing a tab label or button selects its text and shows a selection menu, which feels wrong in an app. The fix is selective: disable selection on UI chrome, never on content users may want to copy (messages, addresses, order numbers, error details).
/* UI chrome: navigation, buttons, toolbars, tab labels. */
.tab-bar,
.app-bar,
button,
[role="tab"],
.chip {
-webkit-user-select: none; /* still required by Safari */
user-select: none;
}
/* Content stays selectable, even inside non-selectable containers. */
.message-body,
.order-number,
.error-details {
-webkit-user-select: text;
user-select: text;
}
/* One tap selects the whole token: codes, IDs, API keys. */
.copyable-code {
-webkit-user-select: all;
user-select: all;
}
MDN's compatibility data shows Safari (macOS and iOS) still requires the -webkit- prefix for user-select in shipping releases; the unprefixed property is only in Safari Technology Preview. Always write both. user-select: none doesn't prevent copying via keyboard shortcuts in all browsers and isn't a content-protection mechanism.
Typography: system fonts¶
Native apps use the platform's UI font: San Francisco on Apple platforms, Roboto or the OEM font on Android, Segoe UI on Windows. The system-ui generic family maps to it, with no download, no layout shift and no flash of invisible text. It's supported in Chrome 56, Firefox 92 and Safari 11.
:root {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
/* Respect the user's text size: size text in rem, never set px on :root. */
font-size: 100%;
line-height: 1.5;
-webkit-text-size-adjust: 100%; /* stop iOS inflating text in landscape */
text-size-adjust: 100%;
}
code,
pre,
kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
Notes on the stack:
-apple-systemis the older WebKit name for the same font; it matters only for very old Safari and Firefox on macOS.ui-sans-serif,ui-serif,ui-monospaceandui-roundedare only implemented by Safari (13.1 and later). They're harmless elsewhere but need fallbacks.- On Windows,
system-uiresolves to a locale-dependent font, which can surprise you for CJK locales. Test your supported languages. - Brand fonts are fine for headings and marketing views. Keep dense UI (lists, forms, navigation) in the system font for legibility and speed, and see Loading Performance for
font-displayand preloading when you do ship web fonts.
Honoring the user's text size¶
Mobile operating systems let users enlarge text system-wide, and native apps follow that setting. Browsers historically didn't pass it to web content. Two mechanisms change that:
- Safari on iOS exposes Dynamic Type through system font keywords such as
font: -apple-system-body, which scale with the user's text size setting (see WebKit's Using the System Font in Web Content). - Chromium added
<meta name="text-scale" content="scale">in Chrome 146, which makes the rootfont-sizescale with OS and browser text-size settings, and theenv(preferred-text-scale)variable in Chrome 138. MDN marks the meta tag experimental. If you opt in, size all text inrem/em, never override the rootfont-sizewith pixels, and test at 200% and beyond.
Perceived performance: skeleton screens¶
A spinner says "wait". A skeleton, a gray outline of the content about to appear, says "almost there" and prevents layout shift when content arrives. Native apps use them for any view whose data takes more than a few hundred milliseconds. Rules that make skeletons work:
- Match the real layout exactly (same heights, gaps and line counts) so content replaces it without shifting. That protects CLS (Core Web Vitals).
- Show them only after a short delay (around 150–300 ms). Instant responses shouldn't flash a skeleton.
- Put the skeleton in the app shell so it renders from the service worker cache on the first frame (App Shell Model).
- Make them accessible: mark the region
aria-busy="true"while loading and hide the placeholder shapes from assistive technology. - Respect reduced motion: the shimmer animation goes away under
prefers-reduced-motion: reduce.
<section id="inbox" aria-busy="true" aria-labelledby="inbox-title">
<h2 id="inbox-title">Inbox</h2>
<ul class="skeleton-list" aria-hidden="true">
<li class="skeleton-row"><span class="sk sk-avatar"></span><span class="sk sk-line"></span><span class="sk sk-line sk-short"></span></li>
<li class="skeleton-row"><span class="sk sk-avatar"></span><span class="sk sk-line"></span><span class="sk sk-line sk-short"></span></li>
<li class="skeleton-row"><span class="sk sk-avatar"></span><span class="sk sk-line"></span><span class="sk sk-line sk-short"></span></li>
</ul>
</section>
.skeleton-row {
display: grid;
grid-template-columns: 40px 1fr;
grid-template-rows: 16px 14px;
gap: 8px 12px;
padding: 12px 16px;
min-height: 72px; /* identical to a real row */
}
.sk {
border-radius: 4px;
background: linear-gradient(90deg, var(--sk-base) 0%, var(--sk-shine) 50%, var(--sk-base) 100%);
background-size: 200% 100%;
animation: sk-shimmer 1.4s linear infinite;
}
.sk-avatar { grid-row: span 2; inline-size: 40px; block-size: 40px; border-radius: 50%; }
.sk-short { inline-size: 60%; }
@keyframes sk-shimmer {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sk { animation: none; }
}
/* Skeletons appear only if loading takes longer than 200ms. */
[aria-busy="true"] .skeleton-list {
animation: sk-delay 0s linear 200ms both;
}
@keyframes sk-delay {
from { visibility: hidden; }
to { visibility: visible; }
}
// Loaded with <script type="module"> (top-level await).
const section = document.querySelector("#inbox");
try {
const items = await fetchInbox();
section.querySelector(".skeleton-list").replaceWith(renderList(items));
} catch (error) {
section.querySelector(".skeleton-list").replaceWith(renderError(error));
} finally {
section.setAttribute("aria-busy", "false");
}
When the data can come from a cache, skip the skeleton entirely: render cached content immediately and refresh it in the background (stale-while-revalidate, Caching Strategies). Showing stale content with a "last updated" note beats any skeleton; Offline UX & Fallbacks covers freshness indicators.
Dark mode¶
Native apps follow the system appearance by default. A PWA that stays bright white at night in a dark OS looks broken. Dark mode involves four layers:
| Layer | Mechanism | Support |
|---|---|---|
| Detect the system preference | @media (prefers-color-scheme: dark), matchMedia() | Chrome 76, Firefox 67, Safari 12.1 (iOS 13) |
| Tell the browser which schemes you support, so form controls, scrollbars and the default canvas match | <meta name="color-scheme" content="light dark"> and the color-scheme CSS property | Property: Chrome 81, Firefox 96, Safari 13. Meta: Safari 12.1 |
| Pick colors per scheme in one declaration | light-dark(<light>, <dark>) | Chrome 123, Firefox 120, Safari 17.5 |
| Color browser and OS chrome | <meta name="theme-color" media="(prefers-color-scheme: …)"> | See Splash Screens & Theming |
The meta tag version of color-scheme matters for more than form controls: the browser reads it before any CSS loads, so the initial canvas is dark rather than flashing white on launch. That's half of avoiding the launch flash; the other half is in Splash Screens & Theming.
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111318" media="(prefers-color-scheme: dark)">
<script>
// Runs before first paint: apply a saved manual override, if any.
try {
const saved = localStorage.getItem("theme"); // "light" | "dark" | null
if (saved === "light" || saved === "dark") {
document.documentElement.dataset.theme = saved;
}
} catch {
/* storage blocked: follow the system */
}
</script>
:root {
color-scheme: light dark; /* follow the system by default */
--surface: light-dark(#ffffff, #111318);
--on-surface: light-dark(#1b1c1f, #e3e2e6);
--on-surface-muted: light-dark(#5d5e66, #a9a9b3);
--outline: light-dark(#dcdce2, #2c2d33);
--primary: light-dark(#0b57d0, #a8c7fa);
--pressed-overlay: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.12));
--hover-overlay: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
--sk-base: light-dark(#ececf1, #23242a);
--sk-shine: light-dark(#f6f6f9, #2d2e35);
}
/* A manual override pins the scheme; light-dark() follows color-scheme. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
html {
background: var(--surface);
color: var(--on-surface);
}
light-dark() resolves against the element's used color-scheme, so setting color-scheme: dark on :root switches every token at once, including for a manual override. For browsers older than the versions above, duplicate the tokens in a @media (prefers-color-scheme: dark) block and a [data-theme="dark"] block.
A manual toggle must also update the theme color, or the status bar and title bar keep the old scheme:
/**
* Switch between "light", "dark" and "system", persist the choice and keep
* <meta name="theme-color"> in sync with the effective scheme.
*/
const THEME_COLORS = { light: "#ffffff", dark: "#111318" };
const systemDark = matchMedia("(prefers-color-scheme: dark)");
export function applyTheme(choice) {
const root = document.documentElement;
if (choice === "system") delete root.dataset.theme;
else root.dataset.theme = choice;
try {
if (choice === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", choice);
} catch {
/* ignore: the choice just won't persist */
}
syncThemeColor();
}
function syncThemeColor() {
const forced = document.documentElement.dataset.theme;
const metas = document.querySelectorAll('meta[name="theme-color"]');
for (const meta of metas) {
const scheme = meta.media.includes("dark") ? "dark" : "light";
// With an override, both tags carry the override color so whichever
// media query matches, the browser shows the right one.
meta.content = forced ? THEME_COLORS[forced] : THEME_COLORS[scheme];
}
}
systemDark.addEventListener("change", syncThemeColor);
syncThemeColor();
Images need attention too: provide dark variants of illustrations with <picture><source srcset="…" media="(prefers-color-scheme: dark)">, avoid pure black backgrounds (they smear on OLED screens when scrolling), and check contrast in both schemes. Users of Windows high contrast themes get forced-colors: active; test that your controls stay visible (Accessibility).
Standalone-only CSS and behavior¶
Some UI only makes sense inside the installed app (a back button, a share button in place of the address bar), and some only outside it (an install prompt, "open in the app" banners). The display-mode media feature targets them; it's supported in Chrome 42, Firefox 47 and Safari 13 (iOS 12.2).
/* App-only chrome is hidden by default, i.e. in a browser tab. */
.app-only {
display: none;
}
@media (display-mode: standalone), (display-mode: fullscreen),
(display-mode: window-controls-overlay), (display-mode: minimal-ui) {
.app-only {
display: revert;
}
.browser-only {
display: none;
}
}
/* Safari: an iOS web app whose manifest says "standalone" matches
display-mode: fullscreen (WebKit bug 264218), and one with no manifest
reports "browser". A class set from navigator.standalone covers both
(see standalone-class.js below). */
:root.is-safari-web-app .app-only {
display: revert;
}
:root.is-safari-web-app .browser-only {
display: none;
}
// navigator.standalone is non-standard. It exists in Safari on iOS/iPadOS and,
// since Safari 17, on macOS too: false in Safari tabs, true in Home Screen and
// Dock web apps. Its presence doesn't mean iOS; add navigator.maxTouchPoints > 0
// if you need to tell an iPhone or iPad from a Mac.
if (navigator.standalone === true) {
document.documentElement.classList.add("is-safari-web-app");
}
Things that commonly differ in standalone mode:
- Install promotion disappears (the app is installed); Install Prompts & Custom UI and Detecting Installed Apps cover the logic.
- External links get an "opens in browser" indicator, because in a standalone window nothing else tells users they're leaving the app.
- Share and copy-link buttons replace the address bar (Web Share API).
- Refresh control appears where no browser reload exists.
- Analytics records the display mode as a dimension so you can compare installed and browser usage (Analytics for PWAs).
Don't use standalone detection to remove functionality from browser users, and don't hide content behind installation. Display Modes explains how each engine computes display-mode and has a complete detection module, including the change event for apps that move between a tab and a window.
Putting it together: a baseline app-like stylesheet¶
The stylesheet below collects the defaults from this page into one file you can start from. It assumes the viewport and color-scheme meta tags shown earlier.
/* ---------- Tokens (light/dark) ---------- */
:root {
color-scheme: light dark;
--surface: light-dark(#ffffff, #111318);
--on-surface: light-dark(#1b1c1f, #e3e2e6);
--on-surface-muted: light-dark(#5d5e66, #a9a9b3);
--outline: light-dark(#dcdce2, #2c2d33);
--primary: light-dark(#0b57d0, #a8c7fa);
--pressed-overlay: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.12));
--focus-ring: light-dark(#0b57d0, #a8c7fa);
--app-bar-h: 56px;
--tab-bar-h: 56px;
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
/* ---------- Document ---------- */
html {
background: var(--surface);
color: var(--on-surface);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
/* We provide our own refresh control. */
overscroll-behavior-y: none;
}
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
overscroll-behavior-y: none;
}
/* ---------- Interaction ---------- */
a,
button,
[role="button"],
input,
select,
textarea,
label {
touch-action: manipulation; /* no double-tap zoom delay on controls */
-webkit-tap-highlight-color: transparent;
}
button,
[role="button"] {
min-block-size: 44px;
min-inline-size: 44px;
}
button:active,
[role="button"]:active {
background-color: var(--pressed-overlay);
}
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
input,
select,
textarea {
font: inherit;
font-size: max(16px, 1rem); /* no focus zoom on iOS */
}
/* ---------- Chrome: no selection, no callouts ---------- */
nav,
header,
button,
[role="tab"] {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* ---------- Safe areas ---------- */
.app-bar {
position: sticky;
top: 0;
padding-top: env(safe-area-inset-top, 0px);
padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px));
min-height: calc(var(--app-bar-h) + env(safe-area-inset-top, 0px));
background: var(--surface);
}
main {
padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px));
padding-bottom: calc(var(--tab-bar-h) + env(safe-area-inset-bottom, 0px) + 16px);
}
/* ---------- Inner scrollers ---------- */
[data-scroll-pane] {
overflow-y: auto;
overscroll-behavior: contain;
}
/* ---------- Motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Browser support¶
Support data as of September 2026. Check MDN and caniuse for live data.
| Feature | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|
env(safe-area-inset-*) | ✅ 69 | ✅ 65 | ✅ 11.1 / iOS 11.3 |
env(safe-area-max-inset-*) | ✅ 135 (Android) | ❌ | ❌ |
svh / lvh / dvh | ✅ 108 | ✅ 101 | ✅ 15.4 |
overscroll-behavior | ✅ 63 ⚠️ | ✅ 59 ⚠️ | ✅ 16 ⚠️ |
overscroll-behavior: chain | ✅ 150 | 🧪 157 (beta) | ❌ |
touch-action: manipulation | ✅ 36 | ✅ 52 | ✅ 13 / iOS 9.3 |
touch-action: pan-up etc. | ✅ 55 | ❌ | ❌ |
-webkit-tap-highlight-color | ✅ | ❌ | ✅ iOS only |
Unprefixed user-select | ✅ 54 | ✅ 69 | ❌ (needs -webkit-) |
interactive-widget | ✅ 108 (Android) | ✅ 133 (Android) | ❌ |
| VirtualKeyboard API | ✅ 94 | ❌ | ❌ |
inputmode | ✅ 66 | ✅ 95 (Android 79) | ✅ 12.1 / iOS 12.2 |
enterkeyhint | ✅ 77 | ✅ 94 | ✅ 13.1 / iOS 13.4 |
system-ui | ✅ 56 | ✅ 92 | ✅ 11 |
light-dark() | ✅ 123 | ✅ 120 | ✅ 17.5 |
color-scheme property | ✅ 81 | ✅ 96 | ✅ 13 |
display-mode media feature | ✅ 42 | ✅ 47 | ✅ 13 / iOS 12.2 |
| Navigation API | ✅ 102 | ✅ 147 | ✅ 26.2 |
CloseWatcher | ✅ 126 | ✅ 149 | 🧪 Technology Preview |
<dialog closedby> | ✅ 134 | ✅ 141 | 🧪 Technology Preview |
<meta name="text-scale"> | 🧪 146 (experimental) | ❌ | ❌ |
⚠️ overscroll-behavior had no effect on containers without scrollable overflow until Chrome 144 and Firefox 150; Safari still has that limitation, and Edge's legacy engine treated none as contain.
Common pitfalls¶
- Disabling zoom with
user-scalable=noormaximum-scale=1. Fix input zoom with 16 px fields instead. 100vhapp shells whose bottom bar hides under the mobile toolbar. Use100dvhwith a100vhfallback, orsvhfor content that must fit.- Forgetting
viewport-fit=coverand wondering whyenv(safe-area-inset-*)is always0px, or adding it and forgetting to pad, so the tab bar sits under the home indicator. - Overlays that ignore Android back. Use
<dialog>/popover,CloseWatcher, or a history entry, and clean up the entry when closing via your own button. - Disabling pull-to-refresh without replacing it. Users in standalone mode then have no way to refresh.
touch-action: noneon large containers, which kills scrolling and pinch zoom for touches starting there.user-select: noneonbody. It blocks copying content and breaks some assistive technology workflows.- Hover-only affordances that never appear on touchscreens, and sticky hover states after taps.
- Unprefixed
user-selectonly, which Safari ignores. type="number"for codes and card numbers. Useinputmode="numeric".- Relying on the VirtualKeyboard API without a
visualViewportfallback for Safari and Firefox. - Inner full-height scrollers that break toolbar collapsing, scroll restoration and find-in-page.
Debugging¶
- Android: connect a phone with USB debugging and open
chrome://inspectin desktop Chrome to inspect an installed PWA's window, including the keyboard's effect onvisualViewportandenv()values (Browser DevTools). - iOS: enable Web Inspector in Settings → Apps → Safari → Advanced, connect to a Mac, and choose the device in Safari's Develop menu. Home Screen web apps appear as separate inspectable targets. The iOS Simulator reproduces safe areas and the keyboard.
- Safe areas and keyboard values at runtime: log
getComputedStyle(el).paddingBottomon an element padded withenv(), andvisualViewport.height/offsetTopduring focus, rather than guessing. - Display mode: Chrome DevTools' Application → Manifest pane shows the parsed manifest;
matchMedia("(display-mode: standalone)").matchesin the console confirms what CSS sees. - Touch behavior on desktop: DevTools device mode emulates touch events and
(pointer: coarse), but not tap highlight, callouts, the keyboard or pull-to-refresh. Test those on devices.
Further reading¶
On this site
- Display Modes: how standalone, minimal-ui and window-controls-overlay change the window
- Splash Screens & Theming: theme colors, status bars and launch flashes
- Responsive & Adaptive Design: breakpoints, container queries and foldables
- Accessibility: target sizes, gestures and focus in depth
- View Transitions: animated navigation between screens
- iOS & iPadOS: Home Screen web app specifics
- App Shell Model: instant first paint for skeletons and chrome
- Offline UX & Fallbacks: connectivity states and freshness indicators
External references
- MDN: Viewport meta tag
- Chrome for Developers: Prepare for viewport resize behavior changes
- Chrome for Developers: Chrome on Android edge-to-edge migration guide
- Chrome for Developers: Full control with the VirtualKeyboard API
- W3C: VirtualKeyboard API
- MDN: overscroll-behavior
- MDN: CloseWatcher
- W3C: Understanding SC 2.5.8 Target Size (Minimum)
- web.dev: App design (Learn PWA)
- WebKit: More Responsive Tapping on iOS