WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React PWA Install Prompt for Chrome, Edge and iPhone
  1. Blog
  2. Mobile UI in React
  3. React PWA Install Prompt for Chrome, Edge and iPhone
Mobile UI in React
Mobile UI in React

React PWA Install Prompt for Chrome, Edge and iPhone

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 9 min read

On this page

0%
  1. Which browsers let a page open the install dialog?
  2. What does Chrome need before it fires beforeinstallprompt?
  3. How do you capture beforeinstallprompt in React?
  4. How do you guide Add to Home Screen on iOS?
  5. How does the Wingo UI Install Prompt handle both?
  6. When should you not ask people to install?
  7. How do you pair the install prompt with onboarding?
  8. What should you install next?
  9. FAQ
    1. Does beforeinstallprompt work on iOS?
    2. Why is beforeinstallprompt not firing?
    3. How do I know if my PWA is already installed?
    4. Can I show the install prompt again after people dismiss it?
    5. Is the Wingo UI Install Prompt free?

TL;DR

A React PWA install prompt needs two paths. In Chrome, Edge and Samsung Internet, catch beforeinstallprompt in a client component the root layout renders, call preventDefault, save the event and call its prompt() from your own Install button; on iPhone and iPad no browser fires that event, so show the Share, then Add to Home Screen steps instead. Ask only after people get value, never inside the installed app, an in-app browser or a sign-up flow, and remember a Not now for a couple of weeks.

Published Oct 9, 2026

A React PWA install prompt has to cover two kinds of browsers. Chrome, Edge and Samsung Internet fire beforeinstallprompt, so your own button can open the browser's install dialog. No browser on iPhone or iPad fires it, so the only way in is Share, then Add to Home Screen, and your app has to show people where that is. The usual bugs are a listener in a component that mounts too late and a banner that asks on the first page view. This tutorial builds both paths in Next.js, then covers when not to ask at all.

Which browsers let a page open the install dialog?

Only Chromium browsers. Everywhere else people install from a browser menu, if at all, and the page can only explain where. As of October 2026, per caniuse (opens in a new tab), MDN's installability guide (opens in a new tab) and the Firefox 143 release notes (opens in a new tab):

BrowserbeforeinstallpromptHow people installWhat your UI should do
Chrome and Edge on desktopyesyour button calls prompt(), or the address bar iconan Install button
Chrome and Samsung Internet on Androidyesyour button, or the browser menuan Install button
Safari on iPhone and iPad, plus Chrome, Edge, Firefox and Orion there since iOS 16.4noShare, then Add to Home Screenshow the steps
Safari 17+ on macOS Sonoma or laternoFile, then Add to Dockshow the steps
Firefox on Androidnoa home screen shortcut that opens in the browsernothing
Firefox on desktopnonone from a manifest; on Windows, Firefox 143+ can pin a site to the taskbarnothing
In-app browsers (Instagram, TikTok, Facebook)nonone until the page opens in a real browsernothing

On iPhone, no beforeinstallprompt iOS polyfill can help, because no iOS browser has an install dialog a page can open, and WebKit closed the 2019 request for BeforeInstallPromptEvent (bug 193959 (opens in a new tab)) as WONTFIX. On the Chromium side, the Chrome 157 release notes (opens in a new tab) list a Web Install API (opens in a new tab) (navigator.install(), desktop first) and an <install> element, and Chromium Dash (opens in a new tab) schedules Chrome 157 for stable on November 3, 2026. WebKit's standards position (opens in a new tab) on that API is oppose, so it changes nothing on iPhone. Build on beforeinstallprompt today.

What does Chrome need before it fires beforeinstallprompt?

A manifest, HTTPS and some engagement. web.dev's install criteria (opens in a new tab) require an app that is not installed yet, served over HTTPS, with a manifest that has name or short_name, a 192px and a 512px icon, start_url, and a display of standalone, fullscreen, minimal-ui or window-controls-overlay. The person must also have clicked or tapped the page once and spent 30 seconds on it, at any time. That is why your PWA install button can stay hidden on a first visit even with a perfect manifest. In Chrome DevTools, Application, then Manifest lists the installability errors.

A service worker is optional for installability, per MDN. Chrome's post Revisiting Chrome's installability criteria (opens in a new tab) (last updated December 2023) dropped the fetch() handler requirement for the menu install (Chrome 108 on mobile, 112 on desktop) but said the automatic install prompt still depended on one. If you depend on the event, register a service worker with a fetch handler anyway; offline support needs one too.

In the App Router the manifest is a file convention:

ts
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
id: "/",
name: "Northwind CRM",
short_name: "Northwind",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}

How do you capture beforeinstallprompt in React?

In a module-level store that starts listening when the root layout mounts, not in a component's state. The browser fires the event as soon as the page qualifies, often right after load, and an event that fired before your listener existed is gone, so a listener in a settings page that mounts later never hears it. Call preventDefault() to keep Chrome's own mini-infobar away, store the event, and call prompt() from a click: the web.dev codelab (opens in a new tab) calls it in the button's click handler because it must come from a user gesture, and resets the saved event because prompt() works once. The event type is not in lib.dom.d.ts, because it is Chromium-only and non-standard (MDN (opens in a new tab)).

ts
// hooks/use-before-install-prompt.ts
import { useSyncExternalStore } from "react";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
let deferred: BeforeInstallPromptEvent | null = null;
let listening = false;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
function subscribe(listener: () => void) {
// attached on the first subscribe, in the browser, and kept for the life of the page
if (!listening) {
listening = true;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); // no mini-infobar: the app decides when to ask
deferred = event as BeforeInstallPromptEvent;
notify();
});
window.addEventListener("appinstalled", () => {
deferred = null;
notify();
});
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useBeforeInstallPrompt() {
const event = useSyncExternalStore(subscribe, () => deferred, () => null);
// call it from a click handler
async function install() {
const current = deferred;
if (!current) return "unavailable" as const;
deferred = null; // prompt() works once per event
notify();
await current.prompt();
const { outcome } = await current.userChoice;
return outcome;
}
return { canInstall: event !== null, install };
}

Call the hook in a client component that the root layout renders, and show your button only while canInstall is true. useSyncExternalStore returns null on the server and during hydration, so the button never causes a hydration mismatch. userChoice reports what happened in the dialog your button opened; appinstalled fires for every install, including one from the address bar. Per web.dev (opens in a new tab), after a dismissal the browser usually fires a fresh event right after userChoice resolves, which the store picks up.

How do you guide Add to Home Screen on iOS?

Detect iPhone and iPad, skip it inside the installed app and in in-app browsers, and show the steps with the icons people will look for. iPadOS reports a Mac user agent, so check touch points too. The PWA guide in the Next.js docs (opens in a new tab) tests /iPad|iPhone|iPod/ only, which misses current iPads, and its Add to Home Screen button has no handler.

ts
// call it in an effect or a store snapshot, never during the server render
export function getInstallContext() {
const ua = navigator.userAgent;
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
// Instagram, Facebook, TikTok and Android WebViews have no install path
const inApp = /FBAN|FBAV|Instagram|musical_ly|TikTok|; wv\)/i.test(ua);
const standalone =
window.matchMedia("(display-mode: standalone)").matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
return { ios, inApp, standalone };
}

iOS 26 changed two things. WebKit's Safari 26 beta announcement (opens in a new tab) says every website added to the Home Screen now opens as a web app by default, with an Open as Web App switch people can turn off. And in Safari's default Compact tab layout, Share sits behind the More (⋯) button, so older Add to Home Screen iOS instructions miss a tap. App builders flagged it in a Glide community thread (opens in a new tab) in September 2025, and iPhone Life's iOS 26.4 walkthrough (opens in a new tab) adds a View More tap before Add to Home Screen. The Bottom and Top layouts keep Share on the toolbar, so write steps that work in all three.

How does the Wingo UI Install Prompt handle both?

It is one component for both paths. Install Prompt keeps the event in a page-wide store, like the hook above, and adds what the hook skips: platform detection for Chromium, iPhone, iPad and Safari on a Mac, the standalone and in-app checks, a remembered Not now, and the UI. It renders nothing where there is no install path.

Wait a second for the banner, then tap Install: on an iPhone there is no browser dialog, so a sheet lists the three steps with the icons to look for. The × hides it for 14 days; Show again brings it back.
$ npx wingo-ui@latest add install-prompt
ProInstall Prompt docs

The demo forces platform="ios"; in your app the default auto detects it. The banner shows delay milliseconds (3000 by default) after an install becomes possible, never takes focus, and sits above a Bottom Tab Bar and the home indicator through --bottom-bar-offset and the safe area insets. On iPhone, Install opens a Drawer with numbered steps and an arrow toward Safari's share button; from 768px up it becomes a centered dialog. On a Mac, Safari gets the File, then Add to Dock steps inline.

Tap Install: the demo plays Chrome's install dialog, so the button spins, then reads Installed with a check and the banner leaves. Show again replays it.
$ npx wingo-ui@latest add install-prompt
ProInstall Prompt docs

Mount it once, high in the tree. This wrapper also keeps it off the screens where it would interrupt a task, and rewrites the iPhone steps for iOS 26:

tsx
// components/app-install-prompt.tsx, rendered by app/layout.tsx after {children}
"use client";
import { usePathname } from "next/navigation";
import { InstallPrompt, useInstallPrompt } from "@/components/ui/install-prompt";
// screens where a banner would interrupt a task
const BUSY = /^\/(welcome|signup|checkout)(\/|$)/;
export function AppInstallPrompt() {
// starts the beforeinstallprompt listener even when the first page people open is a busy one
useInstallPrompt();
const pathname = usePathname();
if (BUSY.test(pathname)) return null;
return (
<InstallPrompt
appName="Northwind CRM"
appIcon="/icon-192.png"
description="Open it right from your home screen, even offline."
delay={5000}
dismissDays={30}
labels={{
iosSteps: ["Tap Share, or ⋯ and then Share", "Choose Add to Home Screen (under View More if needed)", "Tap Add"],
}}
/>
);
}

For a settings page, variant="card" places the same prompt inline. For a menu item, useInstallPrompt() returns platform, canInstall, isStandalone and promptInstall(), which resolves accepted, dismissed or unavailable.

When should you not ask people to install?

Ask after people have gotten value, and stay quiet everywhere else. web.dev's install promotion patterns (opens in a new tab) say disruptive promotions hurt usability and engagement, that a sign-up form should not be interrupted, and that a turned-down promotion should stay hidden until something changes, such as a sign-in or a purchase. Skip it:

  • On arrival. Chrome already waits for a tap and 30 seconds; iPhone has no such gate, so your delay is the only one.
  • Inside a task. Sign-up, checkout and onboarding are on the BUSY list above. For a multi-step flow, wait until the last step is done.
  • Inside the installed app. Check display-mode: standalone and navigator.standalone. The component also treats minimal-ui and window-controls-overlay as installed.
  • In an in-app browser. A link opened from Instagram or TikTok has no install path, and the Install Prompt renders nothing there. If much of your traffic arrives that way, the MIT-licensed pwa-install (opens in a new tab) web component added in-app browser detection with guided steps, such as opening the link in Safari, in 0.7.0 (opens in a new tab) (September 2026). Ours does not do that.
  • On top of another banner. One floating message at a time. Fixed install banners queue behind each other, but a cookie banner is yours to sequence.
  • Again right after Not now. The × stores a timestamp in localStorage under storageKey and hides the prompt for dismissDays (14 by default; 0 means this session only).

How do you pair the install prompt with onboarding?

Ask for install after onboarding, and on iPhone ask for notifications inside the installed app. WebKit's iOS 16.4 announcement (opens in a new tab) added Web Push for Home Screen web apps, with the permission request tied to a direct user interaction. The Onboarding Flow asks for notifications and location on its own step, so drop the notification card in a Safari tab:

Tap Allow on each card: this demo answers as a browser that granted it. Continue stays enabled, so both permissions are optional.
$ npx wingo-ui@latest add onboarding-flow
ProOnboarding Flow docs
tsx
// app/welcome/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { OnboardingFlow, type OnboardingPermission } from "@/components/blocks/onboarding-flow";
import { useInstallPrompt } from "@/components/ui/install-prompt";
export default function WelcomePage() {
const router = useRouter();
const { platform, isStandalone } = useInstallPrompt();
// web push on iPhone and iPad works in the Home Screen app, so a Safari tab skips that card
const permissions: OnboardingPermission[] =
platform === "ios" && !isStandalone ? ["location"] : ["notifications", "location"];
return (
<OnboardingFlow
persistKey="onboarding"
permissions={permissions}
onComplete={async (result) => {
await fetch("/api/onboarding", { method: "POST", body: JSON.stringify(result) });
// /welcome is on the BUSY list, so the install banner waits for the home route
router.push("/");
}}
/>
);
}

The block's slides, interests, sample user and labels default to its Acme Market demo content, so pass slides, interests, user and labels for your own app before you ship it. Once people finish, the banner shows after its delay on the home route: they have just set the app up, and no form is competing for the screen. For the rest of the mobile layer, read mobile-first React components, add pull to refresh to the PWA, or browse Mobile UI in React.

What should you install next?

Add the component, then the manifest and the root layout wrapper above. npx wingo-ui@latest add install-prompt copies it with the Button, Drawer and hooks it imports, and installs the npm packages they use (motion, lucide-react, vaul and three Radix primitives). Install Prompt and Onboarding Flow are part of Wingo UI Pro, so sign in first with npx wingo-ui@latest login; the Button and Drawer underneath are free.

Components in this post

  • Install Prompt

    Invites people to install the web app: the browser's install dialog in Chrome and Edge, an Add to Home Screen guide on iPhone and Mac.

    Pro
  • Onboarding Flow

    A marketplace app's first run: welcome slides, a calm ask for notifications and location, a short profile, preferences and a finish.

    Pro

FAQ

Does beforeinstallprompt work on iOS?

No. No browser on iPhone or iPad fires it, and WebKit closed the 2019 request to add BeforeInstallPromptEvent (bug 193959) as WONTFIX. People install from the Share menu, in Safari or, since iOS 16.4, in other browsers that added the option such as Chrome and Edge, so the best a page can do is show those steps.

Why is beforeinstallprompt not firing?

Chrome fires it only for an app that is not installed yet, served over HTTPS, with a manifest that has a name, 192px and 512px icons, a start_url and a standalone-like display, and only after the person has tapped the page once and spent 30 seconds on it at some point. It often fires right after load, so a listener that mounts later on another route misses it; Application, then Manifest in Chrome DevTools lists what is missing.

How do I know if my PWA is already installed?

Inside the installed app, matchMedia('(display-mode: standalone)') matches, and on iOS navigator.standalone is true as well. In Chromium, the appinstalled event fires on the page at the moment of the install, whichever button started it.

Can I show the install prompt again after people dismiss it?

Each beforeinstallprompt event can call prompt() once; after a dismissal you wait for the browser to fire a new event, which web.dev says usually comes right after userChoice resolves. Your own banner should also remember Not now for a while; the Wingo UI Install Prompt hides it for 14 days by default.

Is the Wingo UI Install Prompt free?

No. The Install Prompt and the Onboarding Flow block are Pro items, while the Button and the Drawer the prompt builds on are free. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add install-prompt copies it and everything it imports into your project.

  • Mobile UI
  • PWA
  • Install prompt
  • React
  • Next.js

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Minimum Touch Target Size: WCAG, iOS, Android and Tailwind

Minimum touch target size per WCAG 2.2 (24 and 44px), iOS (44pt) and Android (48dp), and how to get 44px taps in React and Tailwind without desktop bloat.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI