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).
How it works
Section titled “How it works”A page has exactly three installability signals, and every install UX is built from them.
beforeinstallpromptonwindow(Chromium only). Its arrival proves the browser considers the page installable and will honourprompt()from a later user gesture. Before it fires, an install button is a promise the page cannot keep; afterpreventDefault(), the browser’s own banner stays hidden untilprompt()is called.display-mode.matchMedia('(display-mode: standalone)')(andwindow-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 legacynavigator.standaloneboolean.appinstalledonwindow(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.
Examples
Section titled “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
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.
See also
Section titled “See also”- How to provide your own in-app install experience (web.dev)
- Patterns for promoting PWA installation (web.dev)
- WebKit features in Safari 26.0 (webkit.org)
- beforeinstallprompt event
- Installability criteria
- iOS Add to Home Screen
- Desktop installation
Specifications
| Specification | Status |
|---|---|
| None. | |