Skip to content

Display Modes

A display mode controls how much browser UI surrounds your installed app: none at all (fullscreen), only the operating system's window frame or status bar (standalone), a small navigation bar (minimal-ui), or a normal browser tab (browser). You request one with the manifest's display member, or with an ordered wish list in display_override. The browser then applies whichever mode it supports and reports the mode it actually applied through the display-mode media feature. The request and the result often differ, so the gap between them is where most bugs come from: a missing back button on iOS, an install that silently fails because of a typo, a fullscreen game that opens in a normal window on desktop.

Key takeaways

  • The four standard modes form a fixed fallback chain: fullscreen → standalone → minimal-ui → browser. A browser that can't honor your mode walks down the chain, never up.
  • display_override (Chromium 89+) is an ordered list that is tried before display. It is the only place where window-controls-overlay, tabbed and unframed are valid. Put them in display and the value is ignored.
  • Always style and script against the applied mode (@media (display-mode: …), matchMedia()), never against what the manifest asked for. Desktop Chromium turns fullscreen into standalone and browser into minimal-ui. The user can move the app into a tab, and the value changes at runtime.
  • Safari derives display-mode from the manifest's display value, not from the actual window. On iOS, combine the media query with navigator.standalone.
  • Standalone windows remove the back button, reload, share and the address bar on most platforms. Provide in-app replacements that appear only when the display mode calls for them.
  • The manifest orientation member only applies in app-like modes, mostly on Android. screen.orientation.lock() requires fullscreen in Chromium, and Safari doesn't support it at all.

The four standard display modes

The Web Application Manifest spec defines four display modes. Each is a request: the spec lets the browser pick another mode from the fallback chain if it can't or won't honor it.

Mode Spec definition (abridged) What users typically see Fallback
fullscreen "Opens the web application with browser UI elements hidden and takes up the entirety of the available display area." Android: immersive, with the status and navigation bars hidden. Desktop Chromium and iOS show it like standalone standalone
standalone Looks and feels like a standalone native application. Standard browser UI such as the URL bar is excluded; system UI such as window decorations, the status bar and a system back button can remain. A window of its own (desktop), or a full-screen activity with the status bar (mobile), with no address bar minimal-ui
minimal-ui "Similar to standalone, but provides the end-user with some means to access a minimal set of UI elements for controlling navigation." Back and reload buttons in the title bar (desktop Chromium), a compact toolbar (Chrome on Android) browser
browser (default) "Opens the web application using the platform-specific convention for opening hyperlinks." A normal browser tab none

browser is the default whenever display is missing or invalid. That's why desktop Chromium doesn't promote a manifest without display: the effective mode is browser, and Chromium only promotes apps whose mode is standalone, fullscreen, minimal-ui or one of the extended modes described below. Chrome on Android is more lenient with a missing display and only rejects an explicit "browser", but don't rely on that. The Installability Criteria page lists the exact error codes.

The fallback chain

The spec defines the fallback as a strict chain. Each mode falls back to the next one down, and browser is the floor:

flowchart LR
    F["fullscreen"] -->|"not supported"| S["standalone"]
    S -->|"not supported"| M["minimal-ui"]
    M -->|"not supported"| B["browser"]

The chain has a design flaw that motivated display_override. You can't say "I want minimal-ui, but if that's unavailable, give me standalone rather than a browser tab", because standalone sits above minimal-ui in the chain. A new mode such as window-controls-overlay or tabbed has no natural slot in the chain either. See display_override below.

How the display member is parsed

The spec's processing steps for display are short:

  1. Start with the default, browser.
  2. If json["display"] is not a string, stop and keep the default.
  3. Strip leading and trailing ASCII whitespace and convert the value to ASCII lowercase.
  4. If the result is one of fullscreen, standalone, minimal-ui or browser, use it. Otherwise keep the default.

Implementations differ in the details:

Input Spec Chromium WebKit (Safari)
"standalone" standalone standalone standalone
" Standalone " standalone (trimmed, lowercased) standalone (trimmed, case-insensitive match) standalone (trimmed, case-insensitive match)
"standalon" browser Undefined, with the console warning unknown 'display' value ignored. browser, with the warning "standalon" is not a valid display mode.
"window-controls-overlay" browser (not a valid display value) Undefined, with the warning inapplicable 'display' value ignored. browser
42 or null browser Undefined, with the warning property 'display' ignored, type string expected. browser

Chromium keeps an internal "undefined" state rather than storing browser. It later resolves an undefined mode to browser for promotion purposes, which is why display: "standalon" produces the manifest-display-not-supported installability error instead of a clear parse error. Chrome DevTools prints the parser warnings in Application → Manifest, and it's the fastest way to catch typos. See Browser DevTools.

Declared mode versus applied mode

The spec separates the mode you declare in the manifest from the mode the browser applies to a given window. The applied mode can differ for several reasons:

  • The platform doesn't support the declared mode. Desktop operating systems have no "fullscreen app" launch, for example.
  • The user overrode it. Desktop Chromium lets users switch an app between Open in window and Open in browser tab. Users on iOS 26 can turn off "Open as Web App" when they add a site to the Home Screen.
  • The app is running in a context that the manifest doesn't govern: a normal tab, an out-of-scope page, a Document Picture-in-Picture window.
  • The mode changed at runtime. The user entered fullscreen, moved the app into a browser tab, or toggled the window controls overlay.

The spec requires that "a user agent MUST reflect the applied display mode of the web application in the display-mode media feature". Media Queries Level 5 adds that in child browsing contexts (iframes) the display mode matches the top-level browsing context. Base your code on that media feature. The manifest value is only a hint about what you might get.

display_override: a custom fallback chain

display_override is an array of display modes that the browser tries in order, before it looks at display. It is defined in the WICG Manifest Incubations draft, not in the W3C manifest spec, and has shipped in Chromium since version 89 (Chrome, Edge, Opera, Samsung Internet). Safari and Firefox ignore it and use display only, so always keep a sensible display as the baseline.

manifest.webmanifest (excerpt)
{
  "display_override": ["window-controls-overlay", "minimal-ui"],
  "display": "standalone"
}

In a browser that supports all of these, the candidates are evaluated in this order:

  1. window-controls-overlay (from display_override)
  2. minimal-ui (from display_override)
  3. standalone (from display)
  4. minimal-ui (from the fallback chain of display)
  5. browser (from the fallback chain of display)

Processing rules

The Manifest Incubations algorithm processes the member like this:

  1. If display_override is missing or not an array, the result is an empty list. Chromium logs property 'display_override' ignored, type array expected.
  2. Each string entry that is a known display mode, including the extensions window-controls-overlay, tabbed and unframed, is kept. Unknown strings are dropped silently. There is no console warning for a typo inside display_override.
  3. An entry can also be an object, { "display": "…", "url_patterns": [ … ] }. Such an object is kept only if the browser supports URL patterns for that display mode.
  4. To pick the mode, the browser walks the list and returns the first entry it supports. If none is supported, it falls back to processing display with its normal chain.

Per-URL display modes

The object form with url_patterns is a recent addition to the draft. It lets an app use a different display mode for certain URLs. Current Chromium accepts url_patterns only for unframed (see below). For any other mode, an entry with patterns is dropped with the warning display override '<mode>' ignored, url_patterns are not allowed., while plain string entries keep working as before.

Display mode values and where they work

Value Valid in display Valid in display_override Where it takes effect
fullscreen ✅ ✅ Chrome on Android, Firefox for Android. Desktop Chromium resolves it to standalone
standalone ✅ ✅ Every browser that installs apps
minimal-ui ✅ ✅ Chromium on desktop and Android, Firefox for Android. Safari has no minimal-UI presentation: a macOS Dock web app keeps its default navigation toolbar (as with browser or no manifest), and iOS shows no browser controls
browser ✅ ✅ (useless) Everywhere. As the first recognized display_override entry, it makes the app non-installable in Chromium
window-controls-overlay ❌ ✅ Chromium 105+ on Windows, macOS, Linux and ChromeOS
tabbed ❌ ✅ Chromium on ChromeOS. Other desktops need a flag
unframed ❌ ✅ Isolated Web Apps only
picture-in-picture ❌ ❌ Not a manifest value. It exists only as a display-mode media feature value

How desktop Chromium resolves the effective mode

Chromium's desktop logic lives in ResolveEffectiveDisplayMode() in web_app_registrar.cc. Reading it explains several behaviors that surprise developers:

  1. The user's choice comes first. If the user picked Open in browser tab for the app, the result is browser, whatever the manifest says.
  2. display_override is walked next. Each entry is mapped to what an app window can show. An entry counts only if the mapping leaves it unchanged. minimal-ui, standalone, window-controls-overlay and unframed map to themselves, and so does tabbed when the tab strip feature is enabled. fullscreen maps to standalone, and browser maps to minimal-ui, so both are skipped on desktop and the walk continues with the next entry.
  3. display is the fallback. standalone and fullscreen become standalone. browser and minimal-ui become minimal-ui. An app window is never browser, because the user chose a window.
  4. Isolated Web Apps are clamped. For an IWA, minimal-ui and tabbed become standalone, and a browser tab is never allowed.

Two consequences follow. First, "display": "fullscreen" on desktop gives you an ordinary standalone window. If you need real fullscreen on desktop, call element.requestFullscreen() in response to a user gesture. Second, when a site is installed through Chrome's Install page as app without a usable manifest, it gets a standalone window by default. The installed app still behaves as an app, so every UI adaptation on this page applies to it.

How Chrome on Android resolves it

On Android the display mode is baked into the WebAPK or home-screen shortcut when it is created. Chromium's ShortcutInfo::UpdateFromManifest() does the following:

  1. Start from display, if it is set.
  2. Walk display_override and take the first entry that is one of the four standard modes. window-controls-overlay, tabbed and unframed are skipped on Android.
  3. If the result is standalone, fullscreen or minimal-ui, also apply the manifest's orientation (see Orientation).
  4. Finally, UpdateDisplayMode() normalizes the mode for the kind of launcher entry being created. For a WebAPK, any mode that isn't standalone, fullscreen or minimal-ui becomes minimal-ui. For a plain home-screen shortcut, an app-like mode becomes minimal-ui and anything else becomes browser.

For sites without a manifest, the legacy <meta name="mobile-web-app-capable" content="yes"> or apple-mobile-web-app-capable tags still make Chromium's shortcut data default to standalone. Because the WebAPK stores the display mode, changing display later reaches users only through a WebAPK update. See App Identity & Updates.

Worked examples

Manifest Desktop Chromium Chrome on Android Safari (iOS 26, macOS) Firefox for Android
"display": "standalone" standalone window standalone Web app without browser UI standalone
"display": "fullscreen" standalone window Immersive fullscreen Web app; iOS keeps the status bar visible fullscreen
"display": "minimal-ui" Window with back and reload buttons minimal-ui toolbar iOS: web app only if the user keeps "Open as Web App" on. macOS: window with a navigation toolbar minimal-ui
"display": "browser" Not promotable. Install page as app still creates an app window Not promotable; a shortcut opens a tab iOS 26: web app by default. macOS: window with a navigation toolbar Not installable
"display": "standalone", "display_override": ["fullscreen"] standalone (the override is skipped) fullscreen Uses display: standalone Uses display
"display": "standalone", "display_override": ["window-controls-overlay"] WCO when the user enables it, otherwise standalone standalone standalone standalone
"display": "browser", "display_override": ["minimal-ui"] minimal-ui minimal-ui Uses display: browser (no display_override support); iOS 26 still opens it as a web app by default Not installable

Extended display modes

Chromium adds three display modes that are valid only inside display_override, plus one media-feature-only value. Each has its own page or section elsewhere on this site. Here is how they fit into the display mode model.

window-controls-overlay

Available on desktop Chromium (Chrome and Edge 105 and later, on Windows, macOS, Linux and ChromeOS). The app's client area extends over the whole window, including the title bar. The OS window controls (minimize, maximize, close) and the browser's app menu are drawn as an overlay in one corner. Your page draws its own title bar content in the remaining area.

Three details matter for display mode handling:

  • The user has to enable it. Chromium shows a toggle in the title bar, and by default the overlay requires both the manifest opt-in and the user's choice. While the regular title bar is visible, the applied mode is standalone, not window-controls-overlay.
  • The media query tracks the overlay. Chromium reports window-controls-overlay only while the overlay is active and its area isn't empty. Listen for changes (see Listening for display mode changes).
  • The geometry has its own API. navigator.windowControlsOverlay.visible, getTitlebarAreaRect(), the geometrychange event, and the CSS environment variables env(titlebar-area-x), env(titlebar-area-y), env(titlebar-area-width) and env(titlebar-area-height). Mark draggable regions with app-region: drag (Chromium treats -webkit-app-region as an alias). Chromium is also prototyping a standards-track replacement, window-drag: move, behind a runtime flag.

A minimal title bar that works in both states:

titlebar.css
.titlebar {
  display: none; /* only rendered when the overlay is active */
}

@media (display-mode: window-controls-overlay) {
  .titlebar {
    display: flex;
    align-items: center;
    position: fixed;
    /* Fallback values apply if the env() variables are unavailable. */
    left: env(titlebar-area-x, 0);
    top: env(titlebar-area-y, 0);
    width: env(titlebar-area-width, 100%);
    height: env(titlebar-area-height, 33px);
    -webkit-app-region: drag;
    app-region: drag;
  }

  .titlebar :is(button, a, input, select) {
    -webkit-app-region: no-drag; /* interactive controls must stay clickable */
    app-region: no-drag;
  }
}

The complete API, including hit-testing, the geometrychange event and theming, is on Window Controls Overlay.

tabbed

tabbed gives an app window its own tab strip, so a document-style app can keep several documents open in one window. You configure the strip with the tab_strip member (a pinned home_tab and a new_tab_button). Chrome's documentation says the mode "has shipped on ChromeOS". In Chromium's feature configuration it is stable on ChromeOS and experimental everywhere else. On Windows, macOS and Linux, Chromium's manifest parser treats tabbed as unknown unless the desktop tab strip flag is enabled, so the next display_override entry wins.

manifest.webmanifest (excerpt)
{
  "display_override": ["tabbed", "standalone"],
  "display": "standalone",
  "tab_strip": {
    "home_tab": { "scope_patterns": [{ "pathname": "/" }] },
    "new_tab_button": { "url": "/documents/new" }
  }
}

Detect it with @media (display-mode: tabbed). Because the value is unknown to Safari and Firefox, the query simply evaluates to false there, and the rest of your stylesheet is unaffected. tab_strip and the launch_handler interaction are covered in Advanced & Integration Members.

unframed (formerly borderless)

Experimental

unframed is restricted by spec to Isolated Web Apps and is gated behind the window management permission. Chromium's source called it borderless in earlier releases (the Chrome 140 branch still does); the current parser only recognizes unframed. It was a developer trial from Chrome 146 and shipped in Chrome 152 on ChromeOS only, with a gradual rollout. Regular PWAs can't use it.

An unframed window has no host-native title bar and no visible window controls. Web content covers the whole window, and the app defines draggable regions itself with app-region. The spec requires that the display mode stays fixed for the lifetime of the window and that the browser MUST NOT allow out-of-scope navigations inside it. Because there is no title bar, the browser has to show the app's origin and privacy indicators (camera, microphone) elsewhere. It is also the one mode for which Chromium accepts the object form with url_patterns, which lets an IWA use unframed windows only for specific URLs. Query it with @media (display-mode: unframed).

picture-in-picture

picture-in-picture isn't something you request in a manifest. It is the display-mode value of a document shown in a Document Picture-in-Picture window, the always-on-top window that documentPictureInPicture.requestWindow() opens. Chrome 123 added the value on desktop (the Document Picture-in-Picture API itself shipped in Chrome 116), and Firefox 151 added both the API and the media feature value on desktop. The PiP window is a separate document with its own media query context, so styles like these apply only inside the floating window:

pip.css
@media (display-mode: picture-in-picture) {
  body {
    margin: 0;
    font-size: 14px;
  }

  .controls-secondary {
    display: none; /* the floating window is small; keep only essential controls */
  }
}

The media APIs involved are covered on Media & System APIs.

The display-mode media feature

The display-mode media feature is defined in Media Queries Level 5 with the values fullscreen | standalone | minimal-ui | browser | picture-in-picture. It is a discrete feature, so exactly one value matches at a time. Chromium additionally recognizes window-controls-overlay, tabbed and unframed. Browsers that don't know a value treat a query that uses it as not all, so it never matches and never breaks a comma-separated list.

MQ5 also clarifies that the feature isn't limited to installed apps. It "can also be used in non-application contexts to determine whether the viewport is in other modes, such as fullscreen or picture-in-picture". In a normal tab, the value is browser.

app.css
/* Browser tab: show the install promotion and hide app-only chrome. */
.app-only {
  display: none;
}

/* Any app window. List every app-like mode: an unknown value
   in the list is ignored by browsers that don't support it. */
@media (display-mode: standalone),
  (display-mode: minimal-ui),
  (display-mode: fullscreen),
  (display-mode: window-controls-overlay),
  (display-mode: tabbed) {
  .app-only {
    display: revert;
  }

  .install-promo {
    display: none;
  }
}

/* minimal-ui already has a browser back button: don't duplicate it. */
@media (display-mode: minimal-ui) {
  .in-app-back {
    display: none;
  }
}

Reading the display mode from JavaScript

window.matchMedia() evaluates the same queries. Because only one value matches, loop over the values you care about and return the first match:

display-mode.js (excerpt)
const DISPLAY_MODES = [
  "picture-in-picture",
  "unframed",
  "window-controls-overlay",
  "tabbed",
  "fullscreen",
  "standalone",
  "minimal-ui",
  "browser",
];

export function getDisplayMode() {
  for (const mode of DISPLAY_MODES) {
    if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
  }
  // No match at all: a very old engine without display-mode support.
  return "browser";
}

The feature is available in every current engine: Chrome 42, Firefox 47, Safari 13 on macOS and Safari 12.2 on iOS, according to MDN's compatibility data. Firefox for Android fully supports it from version 116. Before that, browser was always true there.

Listening for display mode changes

The applied mode can change while your page is running:

  • Open in app / open in browser. Chromium can move a live page between a browser tab and an app window without reloading it. The Open in button in the address bar moves the tab into an app window. The app menu's Open in Chrome (or Edge) moves it back into a tab. browser switches to standalone or the other way round.
  • Fullscreen. On desktop Chromium, when the window goes fullscreen (the user presses F11 or the page calls requestFullscreen()), the value becomes fullscreen, and it returns to the previous value on exit.
  • Window controls overlay toggle. window-controls-overlay and standalone alternate as the user toggles the title bar.

Each MediaQueryList fires a change event when its result flips. Listen on all of them and recompute:

display-mode.js (excerpt)
export function onDisplayModeChange(callback, { signal } = {}) {
  const lists = DISPLAY_MODES.map((mode) =>
    window.matchMedia(`(display-mode: ${mode})`),
  );
  const handler = (event) => {
    // Every transition fires twice: once for the old value (matches: false)
    // and once for the new one (matches: true). React to the second only.
    if (event.matches) callback(getDisplayMode());
  };
  for (const list of lists) {
    if (typeof list.addEventListener === "function") {
      list.addEventListener("change", handler, { signal });
    } else {
      // Safari before 14 only has the deprecated addListener().
      list.addListener(handler);
      signal?.addEventListener("abort", () => list.removeListener(handler), {
        once: true,
      });
    }
  }
}

MediaQueryList has supported addEventListener("change") since Chrome 39, Firefox 55 and Safari 14. The addListener() branch only matters for old Safari versions, mostly on iOS devices that can't update.

How each engine computes the value

The same query can return different answers for the same manifest, because engines compute it differently.

Chromium's Browser::GetDisplayMode() (quoted here from the Chrome 140 source) checks, in order:

  1. The window is fullscreen (F11 or the Fullscreen API) → fullscreen.
  2. The window is a Document Picture-in-Picture window → picture-in-picture.
  3. The window is an app window:
    • It shows minimal-UI buttons → minimal-ui.
    • It uses the window controls overlay and the overlay area isn't empty → window-controls-overlay.
    • It uses the tab strip → tabbed.
    • It is an unframed IWA window → unframed (borderless in that release).
    • Otherwise → standalone.
  4. Anything else, including every normal tab → browser.

Out-of-scope pages in an app window still report the window's mode.

Chrome's delegate for WebAPKs and Trusted Web Activities returns fullscreen whenever the tab is fullscreen. Otherwise it returns the WebAPK's stored mode, with two corrections in current source. A minimal-ui app reports standalone when its minimal-UI navigation controls aren't actually rendered, and a window-controls-overlay app reports standalone when the header isn't drawn as an overlay. A normal Chrome tab reports browser, or fullscreen during element fullscreen according to the same source, although MDN's compatibility data still lists the fullscreen value as unsupported on Android. Test on a device if you depend on it.

WebKit's display-mode evaluator reads the display value of the applied manifest and returns it verbatim, with one documented exception on iOS described below. Without an applied manifest, the result is browser, including in an iOS 26 Home Screen web app added without a manifest. It ignores the Fullscreen API and the actual window state. MDN's compatibility notes spell out the consequences: in a Safari browser window browser is always true, even in macOS full screen or during element fullscreen. minimal-ui is never true in Safari. The iOS exception: on iOS and iPadOS, a Home Screen web app whose manifest says "display": "standalone" matches display-mode: fullscreen, not standalone (WebKit bug 264218, open since 2023, and MDN's compatibility notes), although iOS always shows the status bar. On iOS, check navigator.standalone === true before any display-mode query.

Desktop Firefox reports fullscreen in its own Full Screen mode (even though tabs can still appear there), and browser otherwise. standalone is never true on desktop. Firefox 143 introduced web apps pinned to the Windows taskbar, and MDN's data says those windows match minimal-ui. Firefox for Android 116 and later reports the installed app's mode correctly.

Detecting an installed launch robustly

"Am I running as an installed app?" sounds like a single media query. In practice you need several signals, because of the engine differences above and because iOS 26 lets users open any site as a web app, with or without a manifest.

installed-context.js
import { getDisplayMode } from "./display-mode.js";

const APP_LIKE_MODES = new Set([
  "standalone",
  "minimal-ui",
  "window-controls-overlay",
  "tabbed",
  "unframed",
]);
const LAUNCH_FLAG = "launched-as-app";

/**
 * Best-effort answer to "is this document running inside an installed app?"
 * Combine signals: no single one is reliable on every platform.
 */
export function isInstalledContext() {
  // 1. iOS/iPadOS Home Screen web apps: non-standard but dependable there.
  if (navigator.standalone === true) return true;

  // 2. The applied display mode.
  const mode = getDisplayMode();
  if (APP_LIKE_MODES.has(mode)) return true;

  // 3. "fullscreen" is ambiguous: an installed fullscreen app, or a browser
  //    tab in F11 / element fullscreen. Use a marker set by start_url.
  if (mode === "fullscreen") return readLaunchFlag();

  return false;
}

/** Call once on startup. start_url is "/?source=pwa" in the manifest. */
export function recordLaunchSource() {
  const params = new URLSearchParams(location.search);
  if (params.get("source") === "pwa") {
    try {
      sessionStorage.setItem(LAUNCH_FLAG, "1");
    } catch {
      // Storage can be unavailable (privacy modes); detection degrades gracefully.
    }
  }
}

function readLaunchFlag() {
  try {
    return sessionStorage.getItem(LAUNCH_FLAG) === "1";
  } catch {
    return false;
  }
}

The start_url marker only covers launches from the app icon. Shortcuts, share targets, file handlers and protocol handlers open other URLs. sessionStorage is per window (and per tab), so the flag survives in-app navigations but doesn't leak into other tabs. Detection techniques that don't depend on the display mode at all, such as getInstalledRelatedApps() and the appinstalled event, are covered in Detecting Installed Apps. For measuring installed usage in analytics, see Analytics for PWAs.

fullscreen display mode versus the Fullscreen API

The manifest spec says the fullscreen display mode "is orthogonal to, and works independently of, the Fullscreen API". MQ5 draws the line precisely:

  • :fullscreen matches an element that is in the fullscreen element stack.
  • (display-mode: fullscreen) matches when the browsing context fills the screen without browser UI. That can happen because of the manifest, because of requestFullscreen() (when it takes the browser to OS-level fullscreen), or because the user used the browser's own fullscreen control.
Situation :fullscreen (display-mode: fullscreen)
Chrome desktop tab, video.requestFullscreen() ✅ on the video ✅ (the window is fullscreen)
Chrome desktop tab, user presses F11 ❌ ✅
Installed Android app with "display": "fullscreen" ❌ ✅
Safari tab, element.requestFullscreen() ✅ ❌ (always browser in a Safari window)
Firefox desktop, user enters Full Screen ❌ ✅

Use :fullscreen and document.fullscreenElement to style content that you put into fullscreen. Use display-mode to adapt your whole layout to the surroundings.

Per-platform behavior

The same manifest produces noticeably different windows on each platform. This section describes what the user sees and what disappears, which drives the UI adaptations in the next section.

Chromium on desktop: Chrome and Edge on Windows, macOS, Linux and ChromeOS

  • standalone opens a separate OS window with a title bar. The title bar is tinted with theme_color (or the page's <meta name="theme-color">) and contains the app icon, the window title and an app menu button. The app menu has the browser-level functions the window lacks: Copy URL, Open in Chrome (or Edge), zoom, print, find, cast, site information and permission settings. The same menu hosts the Uninstall and app settings entries.
  • minimal-ui is the same window plus back and reload buttons in the title bar. The reload button turns into a stop button while a page is loading.
  • fullscreen is not an app launch mode on desktop. It resolves to standalone (see How desktop Chromium resolves the effective mode).
  • Users can change the mode. The app settings offer Open in window versus Open in browser tab, and Open in Chrome moves the current page into a tab. Your UI has to cope with browser mode for an installed app.
  • Keyboard shortcuts still work. Ctrl+R / Cmd+R reloads and Alt+← / Cmd+[ goes back. Few users know the shortcuts, so they don't replace visible controls.

The window title comes from your document, combined with the app's short_name. Chromium's WebAppBrowserController::GetTitle() builds it like this:

Condition Title bar text
An out-of-scope page is showing (the origin bar is visible) short_name only
The page has <meta name="application-title" content="Inbox"> short_name - Inbox (just short_name if the content is empty)
document.title already starts with short_name document.title
document.title is empty short_name
Otherwise short_name - document.title

The application-title meta tag shipped in Chrome 134 (Chrome Platform Status calls it "Document Subtitle"). Use it when your <title> is tuned for tabs and search results ("(3) Inbox – Example Mail") but the app window should show something shorter. The title also appears in Alt+Tab, the taskbar, the Dock and the window switcher, so keep it meaningful per view in single-page apps.

Chrome on Android (WebAPK)

  • standalone runs the app as its own Android task, with its own entry in Recents. The status bar remains visible and is tinted with theme_color. The system back gesture or button walks your session history, and once there is nothing left it closes the app. There is no address bar and no browser menu. Some Android browsers add a persistent notification while the app is in the foreground, from which the user can copy the URL or open the page in the browser.
  • fullscreen hides the status bar and the navigation bar (Android's immersive mode). The user can swipe from the edge to show them temporarily. Use it for games, readers and kiosks, not for regular apps. Users lose the clock and the notification indicators.
  • minimal-ui adds a compact toolbar. web.dev's PWA course describes it as a title bar that shows the current <title> and the origin, with a small menu. As noted above, current Chromium reports standalone rather than minimal-ui whenever the minimal-UI navigation controls aren't actually rendered.
  • browser apps aren't promoted for installation. Add to home screen creates a shortcut that opens a normal tab.
  • Pull-to-refresh stays enabled in standalone apps, which can fight with your own gestures (see Reload and pull-to-refresh).

Samsung Internet, Edge, Opera and other Chromium-based Android browsers follow the same model when they create WebAPKs. When they create browser-badged shortcuts instead, the display depends on the browser. Android covers WebAPK minting and shortcuts.

Firefox

Firefox for Android installs manifest-based apps and honors fullscreen, standalone and minimal-ui (MDN lists support since Firefox 47). It doesn't support display_override. Desktop Firefox has no manifest-based installation. Since Firefox 143, Windows users can pin sites to the taskbar as web apps, which Mozilla's release notes describe as "simplified windows". MDN's compatibility data says these windows match display-mode: minimal-ui. Mozilla doesn't document how they treat the manifest's display value.

Safari on iOS and iPadOS

  • Before iOS 26, a site added to the Home Screen opened as a web app only if its manifest said standalone or fullscreen (or it had the legacy apple-mobile-web-app-capable meta tag). minimal-ui and browser created a bookmark that opened Safari.
  • Since iOS and iPadOS 26, "every website added to the Home Screen opens as a web app" by default, with or without a manifest. The user can turn off Open as Web App in the Add to Home Screen sheet, "even if the site is configured to be a web app". WebKit's announcement sums it up: "there are now zero requirements for 'installability' in Safari".
  • Presentation is always standalone-like. iOS never hides the status bar for a web app, so fullscreen looks like standalone. There is no minimal-UI toolbar either.
  • No browser controls. There is no back button, no reload, no share button and no address bar, and iOS has no system back button either. The only visible navigation controls in a Home Screen web app are the ones you draw.
  • Status bar style. In web app mode, iOS reads <meta name="apple-mobile-web-app-status-bar-style">. Apple's documentation lists three values. default shows the normal status bar. black gives it a black background. With both, content starts below the status bar. black-translucent makes the status bar translucent and lets your content run underneath it, which is what you want for edge-to-edge designs combined with viewport-fit=cover and safe-area insets.
  • navigator.standalone is a non-standard Boolean. It is true inside a Home Screen web app and false in Safari tabs. Since Safari 17 (June 2023, WebKit commit 265004@main) it also exists on macOS, where it is false in tabs and true in Dock web apps, so its presence does not imply iOS: combine it with navigator.maxTouchPoints > 0 if you need to identify iOS or iPadOS. Other browsers leave it undefined.

The iOS & iPadOS page covers the rest of the Apple-specific behavior, including storage separation and Web Push.

Safari on macOS (web apps in the Dock)

Since Safari 17 on macOS Sonoma, File → Add to Dock turns any site into a web app, and it always opens in its own window, "even if the site does not have a manifest file". In Apple's WWDC23 session What's new in web apps, the default window has "a simplified toolbar with navigation buttons", and "the theme color for the site blends into the toolbar". Declaring "display": "standalone" removes the toolbar: "On macOS, the web app will not have a toolbar." Without a manifest, and with minimal-ui or browser, the default toolbar stays. Apple doesn't document what fullscreen does in a Dock web app, so test it before relying on it. Web apps in the Dock support badging, notifications and service workers like Home Screen web apps on iOS.

Platform summary

Desktop Chromium Chrome on Android Safari iOS/iPadOS 26 Safari macOS 17+ Firefox for Android Firefox on Windows (143+)
fullscreen standalone window Immersive, bars hidden Standalone look, status bar visible ⚠️ not documented ✅ Simplified window ⚠️
standalone Window, title bar, app menu Own task, status bar tinted Web app, no browser UI Window without toolbar ✅ Simplified window ⚠️
minimal-ui Window + back/reload Compact toolbar Standalone look Window with navigation toolbar ✅ Simplified window ⚠️
browser Tab (or window via Install page as app) Shortcut to a tab Web app by default in iOS 26 Window with navigation toolbar Not installable Simplified window ⚠️
display_override ✅ ✅ (standard modes only) ❌ ❌ ❌ ❌
User can switch to a tab ✅ ⚠️ varies ❌ ❌ ⚠️ ⚠️

⚠️ = behavior varies or isn't documented by the vendor. Firefox's taskbar web apps report minimal-ui regardless of the cells above, according to MDN.

The Installation by Platform and Desktop Platforms pages show the install flows that lead to these windows.

Display modes apply only to URLs within scope. A URL is within scope when it has the same origin as the manifest's scope and its path starts with the scope's path. The spec doesn't allow browsers to block navigations that leave the scope: "user agents are no longer required or allowed to block off-scope navigations". Instead, "the user agent SHOULD show a prominent UI element indicating the Document/URL or at least its origin, including whether it is served over a secure connection". Each platform does it differently:

flowchart TD
    A["Navigation in an app window"] --> B{"Target URL within scope?"}
    B -- yes --> C["Stay in the app window, applied display mode unchanged"]
    B -- no --> D{"Platform"}
    D -- "Desktop Chromium" --> E["Same window, origin bar with a close button"]
    D -- "Chrome on Android" --> F["Same task, toolbar with the origin and a close button"]
    D -- "iOS / iPadOS" --> G["Opens in an in-app Safari view"]
    D -- "macOS Safari" --> H["Opens in the default browser"]

Desktop Chromium keeps the page in the app window and shows a custom tab bar below the title bar with the page's title, origin and security state. Chromium shows this bar for out-of-scope URLs and also for any page that isn't served over HTTPS (localhost excepted) or that has insecure content. The bar's close button doesn't close the window. It walks back through history to the most recent in-scope entry, and if there isn't one it loads start_url and clears the history. While the bar is visible, the window title becomes the app's short_name.

Chrome on Android keeps out-of-scope pages inside the app's task and shows a Custom Tabs-style toolbar with the origin and a close button, the same UI that Trusted Web Activities show when a user leaves the verified origin.

iOS and iPadOS open out-of-scope links in Safari View Controller, the in-app browser sheet. Apple's WWDC23 session says so for Home Screen web apps.

macOS Safari web apps open out-of-scope links in the user's default browser. The same session notes that window.open() always opens in the web app regardless of scope, and that OAuth flows on a third-party domain are kept in the app by heuristics. When those heuristics fail, window.open() is the documented escape hatch.

Practical rules that follow:

  • Keep authentication in scope, or pop it up. Sign-in on another origin (an identity provider) shows the out-of-scope UI or leaves the app, depending on the platform. A popup (window.open()) keeps the flow attached to the app window everywhere. Passkeys avoid the problem entirely. See Authentication & Passkeys.
  • Choose scope deliberately. A too-narrow scope makes your own pages look foreign. If you also serve pages from another origin you control, look at scope_extensions in Advanced & Integration Members.
  • Test external links in each mode. Clicking a link to another site shouldn't trap the user. In a standalone window without an address bar, the user may not realize they have left your app.
  • Links that open the app. Whether a link clicked elsewhere opens your installed app instead of a tab (link capturing, launch_handler) is a separate mechanism, covered in Protocol Handlers & Launch Handling.

Orientation: the manifest member and the Screen Orientation API

The orientation member

orientation sets the default screen orientation of the installed app. Its values are the OrientationLockType values from the Screen Orientation spec:

Value Meaning
any Any orientation, including upside-down where the device allows it
natural The device's natural orientation: portrait on most phones, landscape on most tablets and laptops
portrait Either portrait orientation, as the platform decides
portrait-primary The primary portrait orientation (angle 0° on a phone)
portrait-secondary Upside-down portrait
landscape Either landscape orientation
landscape-primary The primary landscape orientation
landscape-secondary The other landscape orientation
manifest.webmanifest (excerpt)
{
  "display": "fullscreen",
  "orientation": "landscape"
}

The spec says the value "serves as the default screen orientation for the life of the web application (unless overridden by some other means at runtime)" and makes the Screen Orientation API optional for browsers. Support is narrow:

  • Chrome on Android applies it to WebAPKs and shortcuts, but only when the resolved display mode is standalone, fullscreen or minimal-ui. In browser mode it is ignored.
  • Firefox for Android supports it (MDN lists Firefox 79).
  • Desktop browsers ignore it. Windows can be resized freely.
  • Safari doesn't apply it, according to MDN's compatibility data. WebKit's manifest parser reads the member, but iOS web apps rotate with the device.
  • Android 16 ignores orientation restrictions for apps targeting API level 36 on displays whose smallest width is at least 600 dp (tablets, unfolded foldables, desktop windowing). Expect a locked orientation not to hold on large screens as the browsers that host WebAPKs adopt that target.

Design for both orientations anyway. orientation is a preference, and the rest of the web platform (split-screen, desktop windows, foldables) makes any fixed aspect ratio fragile. See Responsive & Adaptive Design.

The Screen Orientation API at runtime

screen.orientation is a ScreenOrientation object: type (for example "portrait-primary"), angle (0, 90, 180 or 270), a change event, and the methods lock(orientation) and unlock(). Reading the orientation works everywhere: Chrome 38, Firefox 43 and Safari 16.4. Locking is the hard part.

lock() returns a promise and runs these checks, in spec order:

  1. Common safety checks. The document must be a fully active descendant of a top-level traversable with user attention, otherwise an InvalidStateError. It must not be in a sandboxed iframe without allow-orientation-lock, and it must not be hidden. Both cases throw a SecurityError.
  2. Support. If the browser can't lock at all, the promise rejects with NotSupportedError. That's the case on desktop Chrome, where it "always throws NotSupportedError" according to MDN. Safari doesn't support locking at all, so feature-detect the method before calling it.
  3. Pre-lock conditions. The spec says a browser "MUST restrict the use of lock() to simple fullscreen documents", and "SHOULD require installed web applications to be presented in the 'fullscreen' display mode". The spec rejects with NotAllowedError when the conditions aren't met.
  4. Supersession. Another lock() call, or unlock(), rejects a pending promise with AbortError.

Chromium's implementation on Android checks for fullscreen in the browser process. lock() succeeds if the page is in element fullscreen or the app's display mode is fullscreen. A standalone WebAPK therefore still has to call requestFullscreen() first. When the condition fails, Chromium rejects with a SecurityError (not the spec's newer NotAllowedError) and the message The page needs to be fullscreen in order to call screen.orientation.lock(). Exiting fullscreen fully unlocks the orientation. unlock() returns the screen to the default orientation, which is the manifest's orientation for an installed app. Firefox 144 added lock() and unlock() on Android and on Windows tablets.

A landscape game mode that degrades gracefully:

orientation.js
/**
 * Enter a landscape "game mode". Must be called from a user gesture
 * (click/keydown), because requestFullscreen() needs transient activation.
 * Resolves to true if the orientation is locked, false if the caller
 * should show a "rotate your device" hint instead.
 */
export async function enterLandscapeMode(element = document.documentElement) {
  const orientation = screen.orientation;
  if (!orientation || typeof orientation.lock !== "function") return false;

  // Chromium only allows lock() in element fullscreen or the fullscreen
  // display mode, so go fullscreen first unless the app already is.
  // Boolean(): fullscreenElement is undefined (not null) where the Fullscreen API is missing.
  const alreadyFullscreen =
    Boolean(document.fullscreenElement) ||
    window.matchMedia("(display-mode: fullscreen)").matches;

  if (!alreadyFullscreen && element.requestFullscreen) {
    try {
      await element.requestFullscreen({ navigationUI: "hide" });
    } catch (error) {
      console.warn("Fullscreen request rejected:", error.name);
      return false; // no activation, or fullscreen disallowed (iframe, policy)
    }
  }

  try {
    await orientation.lock("landscape");
    return true;
  } catch (error) {
    switch (error.name) {
      case "NotSupportedError": // desktop browsers, Safari
      case "SecurityError": // Chromium: not fullscreen; also hidden or sandboxed documents
      case "NotAllowedError": // spec: pre-lock conditions not met
      case "InvalidStateError": // document not fully active or without user attention
        return false;
      case "AbortError": // a later lock()/unlock() superseded this call
        return orientation.type.startsWith("landscape");
      default:
        throw error;
    }
  }
}

export async function exitLandscapeMode() {
  try {
    screen.orientation?.unlock?.();
  } catch {
    // unlock() throws where locking is unsupported; nothing to undo.
  }
  if (document.fullscreenElement) await document.exitFullscreen();
}

Pair it with a CSS fallback for platforms that can't lock:

orientation.css
.rotate-hint {
  display: none;
}

/* Shown only while the game view is active and the device is upright. */
@media (orientation: portrait) {
  .game-active .rotate-hint {
    display: grid;
    place-items: center;
    position: fixed;
    inset: 0;
  }
}

Adapting your UI to standalone windows

Removing the browser UI also removes the features that lived in it. This table lists what disappears in app-like modes and what you should provide instead:

Browser feature Desktop Chromium standalone Chrome on Android standalone iOS/iPadOS web app What to provide
Back button ❌ (keyboard shortcut only) ✅ system back ❌ In-app back button in app-like modes
Reload ❌ (keyboard shortcut only) ⚠️ pull-to-refresh ❌ Refresh control where content can go stale
Address bar / current URL ⚠️ Copy URL in the app menu ❌ ❌ Copy link / Share action
Share ⚠️ varies by version and OS ❌ ❌ navigator.share() button
Open in browser ✅ app menu ⚠️ varies ❌ Rarely needed; link to the web version if relevant
Tab title Window title Recents entry App switcher shows the app name Meaningful document.title / application-title

minimal-ui keeps back and reload on desktop Chromium, so hide your own copies in that mode.

Back navigation

Only show an in-app back button where the platform lacks one, and only when there is somewhere to go back to. The Navigation API exposes navigation.canGoBack, which is accurate for the current navigable. It's available in Chrome 102, Firefox 147 and Safari 26.2. On older browsers, history.length > 1 is a rough fallback: it also counts entries you can't go back to (forward entries, entries from before the app was launched in the same tab).

back-button.js
import { getDisplayMode, onDisplayModeChange } from "./display-mode.js";

// Modes where the platform shows no back button of its own.
const NEEDS_IN_APP_BACK = new Set([
  "standalone",
  "fullscreen",
  "window-controls-overlay",
  "unframed",
]);

function canGoBack() {
  if (window.navigation && typeof window.navigation.canGoBack === "boolean") {
    return window.navigation.canGoBack;
  }
  return history.length > 1; // coarse fallback for browsers without the Navigation API
}

export function initBackButton(button) {
  const update = () => {
    const mode = getDisplayMode();
    const isIOSWebApp = navigator.standalone === true; // iOS reports its mode inconsistently
    button.hidden = !((NEEDS_IN_APP_BACK.has(mode) || isIOSWebApp) && canGoBack());
  };

  button.addEventListener("click", () => history.back());
  onDisplayModeChange(update);

  if (window.navigation) {
    window.navigation.addEventListener("currententrychange", update);
  } else {
    window.addEventListener("popstate", update);
    window.addEventListener("pageshow", update);
  }
  update();
}

On Android, the system back gesture already walks history. Handle it like any other navigation: close open dialogs and sheets on popstate, or with the navigate event of the Navigation API, instead of letting back leave the app unexpectedly. App-Like UX Patterns covers history management for modals and drawers.

Reload and pull-to-refresh

Chrome on Android keeps pull-to-refresh in standalone apps. If your app has its own scrolling panes or gestures, disable the browser's version with overscroll-behavior-y: contain on the scrolling element, or on body for the document. Then offer your own refresh where data can go stale, for example a button, or a gesture implemented with pointer events. In iOS web apps and desktop standalone windows there is no visible reload control at all. A stuck app without one forces users to kill and relaunch it, so make error states include a Try again action.

When a new service worker version is waiting, "reload" also means "apply the update". Combine your refresh control with the update flow from Updating Service Workers.

Sharing and copying the current URL

Without an address bar, users can't copy the URL of what they're looking at. Offer a share action that uses the Web Share API where it exists and falls back to the clipboard. navigator.share() is available in Safari 12.1+, Chrome on Android, Chrome on desktop (Windows and ChromeOS since 89; MDN lists full desktop support from Chrome 128) and Firefox for Android. It requires transient user activation.

share-link.js
/**
 * Share the current page, or copy its URL where sharing isn't available.
 * Call from a click handler: share() needs transient user activation, and
 * Safari also requires a user gesture for clipboard writes.
 * @returns {Promise<"shared" | "copied" | "cancelled" | "failed">}
 */
export async function shareCurrentPage() {
  // Prefer the canonical URL: in-app URLs may carry state (?source=pwa, filters).
  const canonical = document.querySelector('link[rel="canonical"]')?.href;
  const data = { title: document.title, url: canonical || location.href };

  if (navigator.canShare?.(data)) {
    try {
      await navigator.share(data);
      return "shared";
    } catch (error) {
      if (error.name === "AbortError") return "cancelled"; // user closed the sheet
      // NotAllowedError (no activation, permissions policy) or others: fall back.
    }
  }

  try {
    await navigator.clipboard.writeText(data.url);
    return "copied";
  } catch {
    return "failed"; // clipboard blocked; show the URL in a dialog as a last resort
  }
}

The full API, including files and permission policy, is on Web Share API.

In a standalone window, a link to another site behaves according to the out-of-scope rules above. That's easy to miss, because nothing indicates that a link leaves the app. Conventions that work well:

  • Give external links target="_blank" and rel="noopener". On desktop Chromium they open in a browser tab, which is what users expect for "the web". On iOS they open in the in-app Safari view either way.
  • Mark external links visually (an icon, or "opens in browser" text for screen readers).
  • Don't target="_blank" your own in-scope links. On desktop Chromium, that can open a second app window, which feels broken in a single-window app.

The complete module at the end of this section implements this automatically for links added at runtime.

Status bar, title bar, theme color and safe areas

  • theme_color in the manifest sets the default color of the Android status bar, the desktop title bar and the macOS Safari toolbar. The page's <meta name="theme-color"> overrides it at runtime and can change per page or state. The HTML media attribute lets you provide separate light and dark values. MDN's compatibility data notes that desktop Chrome uses the color only in installed apps, and that from Safari 26 the theme color is only used for installed web apps.
  • Contrast is automatic. Chromium chooses light or dark title bar text and icons based on the color. Test your brand color in both schemes.
  • Edge to edge. For content under the iOS status bar (black-translucent) or under an Android display cutout in fullscreen, add viewport-fit=cover to the viewport meta tag and pad critical content with env(safe-area-inset-top) and its siblings.
index.html (head)
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b57d0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1d3a" media="(prefers-color-scheme: dark)">
<!-- iOS/iPadOS web apps only: let content run under a translucent status bar. -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Chromium desktop app windows: short, per-view window title (Chrome 134+). -->
<meta name="application-title" content="Inbox">
safe-areas.css
.app-header {
  /* max() keeps a minimum padding where the inset is 0 (most desktops). */
  padding-top: max(12px, env(safe-area-inset-top));
  padding-left: max(16px, env(safe-area-inset-left));
  padding-right: max(16px, env(safe-area-inset-right));
}

.app-footer {
  padding-bottom: max(12px, env(safe-area-inset-bottom));
}

Splash screens, background_color, dark-mode theme colors and the iOS startup images are covered in Splash Screens & Theming.

A complete adaptive UI module

The module below ties everything together. It exposes the current mode as a data-display-mode attribute on <html>, keeps it current, wires up back and share buttons, and marks external links. It has no dependencies and works as a native ES module.

display-mode.js
// Detect the applied display mode and adapt app chrome to it.
// Usage: <script type="module" src="/js/display-mode.js"></script>

const DISPLAY_MODES = [
  "picture-in-picture",
  "unframed",
  "window-controls-overlay",
  "tabbed",
  "fullscreen",
  "standalone",
  "minimal-ui",
  "browser",
];

// Modes in which the browser shows no back button of its own.
const NEEDS_IN_APP_BACK = new Set([
  "standalone",
  "fullscreen",
  "window-controls-overlay",
  "unframed",
]);

// Must match the manifest's "scope" (resolved against the manifest URL).
const APP_SCOPE = new URL("/", location.origin);

export function getDisplayMode() {
  for (const mode of DISPLAY_MODES) {
    if (window.matchMedia(`(display-mode: ${mode})`).matches) return mode;
  }
  return "browser";
}

export function onDisplayModeChange(callback, { signal } = {}) {
  for (const mode of DISPLAY_MODES) {
    const list = window.matchMedia(`(display-mode: ${mode})`);
    const handler = (event) => {
      if (event.matches) callback(getDisplayMode());
    };
    if (typeof list.addEventListener === "function") {
      list.addEventListener("change", handler, { signal });
    } else {
      list.addListener(handler); // Safari < 14
      signal?.addEventListener("abort", () => list.removeListener(handler), {
        once: true,
      });
    }
  }
}

/** Manifest scope matching: same origin and a path prefix. */
export function isWithinScope(url) {
  const target = new URL(url, location.href);
  return (
    target.origin === APP_SCOPE.origin &&
    target.pathname.startsWith(APP_SCOPE.pathname)
  );
}

function isAppLike(mode) {
  return navigator.standalone === true || (mode !== "browser" && mode !== "picture-in-picture");
}

function canGoBack() {
  if (window.navigation && typeof window.navigation.canGoBack === "boolean") {
    return window.navigation.canGoBack;
  }
  return history.length > 1;
}

function updateChrome() {
  const mode = getDisplayMode();
  const root = document.documentElement;
  root.dataset.displayMode = mode;
  root.classList.toggle("is-app", isAppLike(mode));

  const back = document.querySelector("[data-action='back']");
  if (back) {
    const needsBack = NEEDS_IN_APP_BACK.has(mode) || navigator.standalone === true;
    back.hidden = !(needsBack && canGoBack());
  }
}

function markExternalLinks(root = document) {
  // querySelectorAll() skips the root itself, so check an added <a> element directly.
  const links = [...root.querySelectorAll("a[href]")];
  if (root instanceof Element && root.matches("a[href]")) links.push(root);
  for (const link of links) {
    let url;
    try {
      url = new URL(link.href);
    } catch {
      continue; // unparsable href: leave it alone
    }
    if (!url.protocol.startsWith("http")) continue; // mailto:, tel:, etc.
    if (isWithinScope(url)) continue;
    link.target = "_blank";
    // Keep existing rel tokens (e.g. "nofollow") and add the safe defaults.
    link.relList.add("noopener", "external");
  }
}

async function share(button) {
  const canonical = document.querySelector('link[rel="canonical"]')?.href;
  const data = { title: document.title, url: canonical || location.href };
  if (navigator.canShare?.(data)) {
    try {
      await navigator.share(data);
      return;
    } catch (error) {
      if (error.name === "AbortError") return;
    }
  }
  try {
    await navigator.clipboard.writeText(data.url);
    button.dataset.state = "copied"; // CSS can show a "Link copied" hint
    setTimeout(() => delete button.dataset.state, 2000);
  } catch {
    window.prompt("Copy this link:", data.url); // last resort
  }
}

function init() {
  updateChrome();
  onDisplayModeChange(updateChrome);

  if (window.navigation) {
    window.navigation.addEventListener("currententrychange", updateChrome);
  } else {
    window.addEventListener("popstate", updateChrome);
  }
  window.addEventListener("pageshow", updateChrome); // back/forward cache restores

  document.addEventListener("click", (event) => {
    const target = event.target instanceof Element ? event.target : null;
    const actionEl = target?.closest("[data-action]");
    if (!actionEl) return;
    if (actionEl.dataset.action === "back") history.back();
    if (actionEl.dataset.action === "share") share(actionEl);
  });

  markExternalLinks();
  // Links rendered later (client-side routing) are handled as they appear.
  new MutationObserver((records) => {
    for (const record of records) {
      for (const node of record.addedNodes) {
        if (node instanceof Element) markExternalLinks(node);
      }
    }
  }).observe(document.body, { childList: true, subtree: true });
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
  init();
}
display-mode.css
/* Default (browser tab): no app chrome. */
[data-action="back"],
.app-only {
  display: none;
}

/* The attribute is set by display-mode.js; the media queries cover
   the first paint before the script runs. */
@media (display-mode: standalone), (display-mode: fullscreen),
  (display-mode: minimal-ui), (display-mode: window-controls-overlay) {
  .app-only {
    display: revert;
  }
  .browser-only {
    display: none;
  }
}

html.is-app .app-only {
  display: revert;
}

html.is-app .browser-only {
  display: none;
}

/* The script controls visibility with the hidden attribute. */
html.is-app [data-action="back"]:not([hidden]) {
  display: inline-flex;
}

a[rel~="external"]::after {
  content: " \2197"; /* north-east arrow; pair it with visually hidden text for screen readers */
}

[data-action="share"][data-state="copied"]::after {
  content: " Link copied";
}
index.html (body excerpt)
<header class="app-header">
  <button type="button" data-action="back" hidden aria-label="Back">←</button>
  <h1 class="app-title">Field Notes</h1>
  <button type="button" data-action="share" class="app-only">Share</button>
  <a class="browser-only install-promo" href="/install">Install the app</a>
</header>

The .install-promo link is only one piece of an install flow. The prompt logic itself is in Install Prompts & Custom UI.

Debugging display modes

  • Chrome/Edge DevTools → Application → Manifest shows the parsed display and the manifest parser's warnings (unknown 'display' value ignored., inapplicable 'display' value ignored., property 'display_override' ignored, type array expected.). The Installability section explains manifest-display-not-supported and manifest-display-override-not-supported.
  • Ask the page. Evaluate getDisplayMode() or matchMedia("(display-mode: standalone)").matches in the console of the app window. For an installed desktop app, open DevTools inside the app window (Ctrl+Shift+I / Cmd+Option+I). In a tab you only ever see browser.
  • chrome://web-app-internals lists every installed app with its stored display mode, display_override and user display mode, which tells you whether the user moved the app into a tab.
  • Reinstall after changing display. Desktop Chromium picks up manifest changes through its update check, but Android bakes the mode into the WebAPK. See App Identity & Updates.
  • Android: debug the WebAPK over USB from chrome://inspect. The device's own window is the only place to check immersive mode, the status bar color and the out-of-scope toolbar.
  • iOS and iPadOS: add the site to the Home Screen in the Simulator or on a device, then attach Safari's Web Inspector through the Develop menu. Check navigator.standalone and the display-mode result side by side.
  • Firefox on Windows: pin the site to the taskbar and confirm (display-mode: minimal-ui) matches.

The Browser DevTools page walks through these panels. Automated Testing shows how to run tests against installed-app contexts.

Browser support

Support data as of September 2026. For live data, see MDN: display, MDN: display_override, MDN: display-mode and caniuse.

Feature Chrome / Edge (desktop) Chrome (Android) Safari (macOS) Safari (iOS / iPadOS) Firefox (desktop) Firefox (Android)
Manifest display ✅ 39 ⚠️ ✅ 39 ✅ 17 ⚠️ ✅ 11.3 ⚠️ ⚠️ ✅ 47
display_override ✅ 89 ✅ 89 ⚠️ ❌ ❌ ❌ ❌
window-controls-overlay ✅ 105 ❌ ❌ ❌ ❌ ❌
tabbed ⚠️ ChromeOS only ❌ ❌ ❌ ❌ ❌
unframed ⚠️ 152 (ChromeOS IWAs only) ❌ ❌ ❌ ❌ ❌
display-mode media feature ✅ 42 ✅ 42 ✅ 13 ⚠️ ✅ 12.2 ⚠️ ✅ 47 ⚠️ ✅ 116
display-mode: picture-in-picture ✅ 123 ❌ ❌ ❌ ✅ 151 ⚠️ 151
Manifest orientation ⚠️ parsed, not applied ✅ 39 ❌ ❌ ❌ ✅ 79
screen.orientation.lock() ❌ ✅ 38 (fullscreen required) ❌ ❌ ⚠️ 144 (Windows tablets) ✅ 144

⚠️ Desktop Chromium resolves fullscreen to standalone and browser to minimal-ui for app windows. Chrome on Android uses only the standard modes from display_override. Safari has no fullscreen or minimal-ui presentation. Its display-mode reflects the manifest value rather than the window (see How each engine computes the value). iOS 26 opens every Home Screen site as a web app by default. Firefox desktop has no manifest installation. Its Windows taskbar web apps (143+) report minimal-ui, and standalone never matches. Firefox for Android 151 recognizes the picture-in-picture value in media queries, but it has no Document Picture-in-Picture API, so the query only matters on desktop. MDN lists manifest orientation for desktop Chrome and Edge because the parser is shared, but desktop app windows don't apply it.

Common pitfalls

Putting an extended mode in display. "display": "window-controls-overlay" is dropped with inapplicable 'display' value ignored., the mode falls back to browser, and the app stops being installable. Extended modes belong in display_override, with a standard mode in display.

Starting display_override with browser. Chromium uses the first recognized override for its installability check, so ["browser", "standalone"] fails with manifest-display-override-not-supported.

Expecting fullscreen on desktop. Desktop Chromium opens a standalone window. Use the Fullscreen API from a user gesture.

Using display-mode: standalone as the only installed-app test. It misses minimal-ui, window-controls-overlay and tabbed windows, it's unreliable on iOS, and it becomes false when the user moves your app into a tab. Combine signals, as in Detecting an installed launch robustly.

Reading the mode once at startup. The mode changes at runtime (open in app, open in browser, fullscreen, the WCO toggle). Subscribe to changes.

Hiding the only back button. A standalone app with no in-app navigation strands iOS and desktop users on deep pages. Test every flow in a real app window, not in a tab.

Opening in-scope pages with target="_blank". On desktop Chromium this can spawn extra app windows.

Relying on orientation for layout. Desktop, Safari, split-screen and large Android screens ignore it. Build responsive layouts, and lock only for experiences that truly need it, from fullscreen.

Forgetting theme_color contrast and safe areas. black-translucent on iOS or fullscreen on Android without viewport-fit=cover and safe-area padding puts controls under the status bar or the camera cutout.

Changing display and expecting instant results. Installed Android apps keep the old mode until the WebAPK is updated, and iOS keeps whatever it captured when the app was added.

Further reading

On this site

External references