Skip to content

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 or CloseWatcher in Chromium, a history entry elsewhere).
  • Use width=device-width, initial-scale=1, viewport-fit=cover and never user-scalable=no or maximum-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: manipulation on controls, and give every control a visible :active state.
  • Contain scrolling with overscroll-behavior, pad fixed chrome with env(safe-area-inset-*), and size full-height layouts with dvh/svh instead of vh.
  • Handle the on-screen keyboard with interactive-widget and the visualViewport API everywhere, and the Chromium-only VirtualKeyboard API where you need full control.
  • Summon the right keyboard with type, inputmode, enterkeyhint and autocomplete, disable text selection only on UI chrome, and use system fonts and color-scheme for 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:

  1. 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.
  2. 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).
  3. 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.
  4. Use the platform's primitives. <dialog>, popovers, inputmode, autocomplete and color-scheme give 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)
index.html (head)
<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:

forms.css
/* 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.

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.

index.html (app chrome)
<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>
navigation.css
: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):

tab-bar.js
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:

overlay-history.js
/**
 * 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 and inert background for free. Chrome 134 and Firefox 141 add closedby="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:

router.js
// 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:

touch-targets.css
/* 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: none on 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 :active style.
  • :active on 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 :active while the finger is down. A passive listener on the document costs nothing measurable and doesn't block scrolling.
press-states.css
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;
  }
}
press-states.js
// 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).

long-press.css
/* 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;
}
long-press.js
// 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).

swipe-to-dismiss.js
/**
 * 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.

overscroll.css
/* 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:

pull-to-refresh.js
/**
 * 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:

  1. Add viewport-fit=cover to the viewport meta tag.
  2. 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:

bottom-bar.css
: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 36px as the fallback for safe-area-max-inset-bottom, because Safari on iOS in its single-tab (compact) layout has a maximum bottom offset of 36 px in portrait. The env(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 a 100vh fallback) for the app root so short screens fill the window and long ones scroll the document.
  • Don't size many elements in dvh that resize continuously during scroll: every toolbar movement relayouts them. Prefer svh for 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-layout.css
.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.html (head)
<!-- 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:

keyboard-inset.js
/**
 * 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.css
.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.
virtual-keyboard.js
// 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();
}
composer.css (Chromium enhancement)
/* 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.

verify.html
<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>
enterkeyhint.js
// 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).

selection.css
/* 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.

typography.css
: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-system is 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-monospace and ui-rounded are only implemented by Safari (13.1 and later). They're harmless elsewhere but need fallbacks.
  • On Windows, system-ui resolves 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-display and 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 root font-size scale with OS and browser text-size settings, and the env(preferred-text-scale) variable in Chrome 138. MDN marks the meta tag experimental. If you opt in, size all text in rem/em, never override the root font-size with 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.
list.html
<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.css
.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; }
}
load-inbox.js
// 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.

index.html (head)
<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>
theme.css
: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:

theme-toggle.js
/**
 * 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).

standalone.css
/* 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;
}
standalone-class.js
// 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.

app-baseline.css
/* ---------- 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=no or maximum-scale=1. Fix input zoom with 16 px fields instead.
  • 100vh app shells whose bottom bar hides under the mobile toolbar. Use 100dvh with a 100vh fallback, or svh for content that must fit.
  • Forgetting viewport-fit=cover and wondering why env(safe-area-inset-*) is always 0px, 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: none on large containers, which kills scrolling and pinch zoom for touches starting there.
  • user-select: none on body. 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-select only, which Safari ignores.
  • type="number" for codes and card numbers. Use inputmode="numeric".
  • Relying on the VirtualKeyboard API without a visualViewport fallback 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://inspect in desktop Chrome to inspect an installed PWA's window, including the keyboard's effect on visualViewport and env() 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).paddingBottom on an element padded with env(), and visualViewport.height/offsetTop during focus, rather than guessing.
  • Display mode: Chrome DevTools' Application → Manifest pane shows the parsed manifest; matchMedia("(display-mode: standalone)").matches in 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

External references