Skip to content

Installation · Concept

Install prompt UX

Published

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).

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.

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

Section titled “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.

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

Section titled “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.

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.

Specifications

SpecificationStatus
None.