Building a custom install prompt for your PWA

Most modern browsers provide a default installation UI for Progressive Web Apps (PWAs), which lets users add your app to their launcher or home screen. You can also build your own in-app installation experience, giving you greater control over when and how users are prompted to install.

Before building a custom flow, make sure your PWA meets the standard installability requirements, including having a valid web app manifest.

Consider your users' habits first. People who open your PWA frequently across the week may benefit from launching it from a home screen or desktop Start menu. Apps that gain from a clean, immersive window—such as productivity tools or media players—also benefit from the extra space available in standalone or window-control-overlay display modes.

Adding a custom install button

To show installability and trigger your own install flow, you need to:

  1. Listen for the beforeinstallprompt event.
  2. Store a reference to the event so you can invoke it later.
  3. Expose a button or other UI element that users can click to start installation.

The browser fires beforeinstallprompt when your PWA meets the required install criteria. You can use this event to update your interface and indicate availability, then save the event for later use in a variable such as deferredPrompt.

// Initialize deferredPrompt for use later to show browser install prompt.
let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
  // Update UI notify the user they can install the PWA
  showInstallPromotion();
  // Optionally, send analytics event that PWA install promo was shown.
  console.log(`'beforeinstallprompt' event was fired.`);
});

When your user interacts with your button or element, call prompt() on the saved beforeinstallprompt event via deferredPrompt. This launches the browser's native modal install dialog so the user can confirm the installation.

buttonInstall.addEventListener('click', async () => {
  // Hide the app provided install promotion
  hideInstallPromotion();
  // Show the install prompt
  deferredPrompt.prompt();
  // Wait for the user to respond to the prompt
  const { outcome } = await deferredPrompt.userChoice;
  // Optionally, send analytics event with outcome of user choice
  console.log(`User response to the install prompt: ${outcome}`);
  // We've used the prompt and can't use it again, throw it away
  deferredPrompt = null;
});

Note that prompt() can only be called once for a given deferred event. The userChoice property is a promise that resolves with the user's choice. If the user dismisses the dialog, you'll need to wait for a new beforeinstallprompt event—usually fired again shortly after userChoice resolves—before you can offer installation again.

Handling successful and other launch modes

The userChoice promise only reveals whether the user installed your app via your custom UI. If the user installs it through a browser-provided mechanism like the address bar, use the appinstalled event instead, which fires whenever your PWA is installed regardless of the method used.

window.addEventListener('appinstalled', () => {
  // Hide the app-provided install promotion
  hideInstallPromotion();
  // Clear the deferredPrompt so it can be garbage collected
  deferredPrompt = null;
  // Optionally, send analytics event to indicate successful install
  console.log('PWA was installed');
});

To detect how users launch your PWA—whether in a browser tab, or as an installed application—use the CSS display-mode media query. With matchMedia(), you can test which mode the app was launched in and adjust your UI accordingly, for example hiding an install button inside the installed app.

function getPWADisplayMode() {
  if (document.referrer.startsWith('android-app://'))
    return 'twa';
  if (window.matchMedia('(display-mode: browser)').matches)
    return 'browser';
  if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone)
    return 'standalone';
  if (window.matchMedia('(display-mode: minimal-ui)').matches)
    return 'minimal-ui';
  if (window.matchMedia('(display-mode: fullscreen)').matches)
    return 'fullscreen';
  if (window.matchMedia('(display-mode: window-controls-overlay)').matches)
    return 'window-controls-overlay';

  return 'unknown';
}

You can also track when the user switches between display modes, such as from browser to an installed mode, by listening for changes on the display-mode media query.

// Replace "standalone" with the display mode used in your manifest
window.matchMedia('(display-mode: standalone)').addEventListener('change', () => {
  // Log display mode change to analytics
  console.log('DISPLAY_MODE_CHANGED', getPWADisplayMode());
});

For visual adjustments based on launch type, use conditional CSS. This allows you to apply separate styling to your PWA when it’s installed versus when it runs in the browser tab, like background colors.

@media all and (display-mode: standalone) {
  body {
    background-color: yellow;
  }
}

If you later need to change your app's name or icon in the launcher, manage the web app manifest. Chrome handles manifest updates automatically, but the way changes are applied is described in its documentation on manifest updates behavior. You may want to inform users about how those updates will take effect.