# Install prompt UX

> The three signals a page has about installability (the beforeinstallprompt event, display-mode, appinstalled), what each browser's own install surface looks like, the cost of prompting early, and code that shows the right affordance per platform.

Install prompt UX is the set of decisions about when a page reveals its own install affordance, what it says, and what it does where no programmatic prompt exists. Only Chromium browsers (Chrome 68 on Android, Chrome 73 on desktop, Edge 79, Samsung Internet 9.0) give the page a `beforeinstallprompt` event to defer; Safari on iOS, macOS Safari 17, Firefox for Android, and Firefox 143 on Windows install only from their own menus, so the page's job there is to describe the route, not to open it (BCD `api.BeforeInstallPromptEvent`).

## How it works

A page has exactly three installability signals, and every install UX is built from them.

1. **`beforeinstallprompt` on `window`** (Chromium only). Its arrival proves the browser considers the page installable and will honour `prompt()` from a later user gesture. Before it fires, an install button is a promise the page cannot keep; after `preventDefault()`, the browser's own banner stays hidden until `prompt()` is called.
2. **`display-mode`**. `matchMedia('(display-mode: standalone)')` (and `window-controls-overlay`, `minimal-ui`, `fullscreen`) tells the page it is already running as an installed app, which is the one state in which every install affordance must disappear. iOS Safari additionally exposes the legacy `navigator.standalone` boolean.
3. **`appinstalled` on `window`** (Chromium). Fires after an install from any surface, so the page can hide its button and record the conversion even when the user used the address-bar icon rather than the page's button.

What the browser shows without any page code differs per platform, and the page's copy has to match it:

| Browser | Ambient install surface (English UI) | Programmatic prompt |
|---|---|---|
| Chrome, Android | A mini-infobar at the bottom reading "Add `<app name>` to Home screen", and an "Install app" item in the three-dot menu | `beforeinstallprompt` + `prompt()` |
| Chrome and Edge, desktop | An install icon at the right end of the address bar; menu item "Install `<app name>`…" (Chrome) or Apps › "Install this site as an app" (Edge) | `beforeinstallprompt` + `prompt()` |
| Safari, iOS and iPadOS | Share sheet › "Add to Home Screen"; on iOS 26 the sheet carries an "Open as Web App" switch that is on by default | None |
| Safari, macOS 14 and later | File › "Add to Dock" | None |
| Firefox for Android | Three-dot menu › "Install" (or "Add to Home screen") | None |
| Firefox 143 and later, Windows | Address-bar icon that pins the site as a taskbar web app | None |

The cost of a badly timed prompt is concrete on Chromium: once `userChoice` resolves to `"dismissed"`, the event object is spent and Chrome applies a cool-down before it fires `beforeinstallprompt` for that site again, so the page gets one well-placed attempt per visit at best. Showing the button after a completed task (a saved form, a finished article, a second session) trades a smaller audience for that one attempt landing when intent is highest; showing it on first paint reaches everyone and is dismissed by most, which is the trade-off web.dev's install-promotion guidance documents.

:::observed
On an iPhone running iOS 26 (English UI), the Safari share sheet lists "Add to Home Screen" and the confirmation screen shows an "Open as Web App" switch that is on by default, per WebKit's Safari 26.0 release post; a site therefore no longer needs a manifest `display` value for the Home Screen entry to open without browser chrome. In Chrome for Android (English UI), the ambient banner that `preventDefault()` suppresses reads "Add `<app name>` to Home screen", where the name is the manifest `short_name` when present.
:::

## Examples

Both examples run in the page and share one helper that answers "is this already installed?".

### Revealing the install button only after a completed task

The event is stored on arrival, but the button is revealed by the task-completion handler, not by the event. The `appinstalled` listener covers installs made from the browser's own surfaces.

```js
let deferred = null;
const button = document.querySelector('#install');

window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();
  deferred = event;                       // hold it; do not show anything yet
});

function onTaskCompleted() {
  if (deferred && !isInstalled()) button.hidden = false;
}

button.addEventListener('click', async () => {
  const { outcome } = await deferred.prompt();
  deferred = null;
  button.hidden = true;
  if (outcome === 'accepted') analytics.track('install_accepted');
});

window.addEventListener('appinstalled', () => {
  deferred = null;
  button.hidden = true;
});

function isInstalled() {
  return matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
}
```

If the event does not arrive before the task completes (Chrome's engagement heuristics are not met, or the browser is not Chromium), `deferred` stays `null` and the button stays hidden, which is the correct behaviour: a visible button that `prompt()` cannot back is worse than none.

### Detecting the platform route and showing instructions instead of a button

Where no event exists, the page can still tell the user how to install, and must stop doing so once the app runs standalone.

```js
function installRoute() {
  if (matchMedia('(display-mode: standalone)').matches || navigator.standalone === true) {
    return null;                                            // already installed: say nothing
  }
  if ('onbeforeinstallprompt' in window) return 'event';   // Chromium: wait for the event
  const ua = navigator.userAgent;
  if (/iPhone|iPad/.test(ua)) return 'Tap Share, then "Add to Home Screen".';
  if (/Macintosh/.test(ua) && /Safari/.test(ua) && !/Chrome/.test(ua)) return 'Choose File, then "Add to Dock".';
  if (/Android/.test(ua) && /Firefox/.test(ua)) return 'Open the menu, then "Install".';
  return null;                                              // unknown: show nothing rather than guess
}

const route = installRoute();
if (route && route !== 'event') showHint(route);
```

User-agent sniffing is used here only to pick instruction text, not to decide capability; capability comes from `'onbeforeinstallprompt' in window` and `display-mode`. The `null` branch for unknown browsers avoids telling a user to look for a menu item that does not exist.

## See also

- [How to provide your own in-app install experience](https://web.dev/articles/customize-install) (web.dev)
- [Patterns for promoting PWA installation](https://web.dev/articles/promote-install) (web.dev)
- [WebKit features in Safari 26.0](https://webkit.org/blog/17333/webkit-features-in-safari-26-0/) (webkit.org)
- [beforeinstallprompt event](/reference/installation/install-prompt/)
- [Installability criteria](/reference/installation/installability-criteria/)
- [iOS Add to Home Screen](/reference/installation/ios-add-to-home-screen/)
- [Desktop installation](/reference/installation/desktop-install/)