Install PWA Button
An "Install app" button that catches beforeinstallprompt at module scope, hides itself when it cannot act, and falls back to written steps on iOS.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/install-pwa-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InstallPwaButton" for triggering a PWA
install, plus the headless hook it is built on (lucide-react for icons).
Contract
- Export a forwardRef component whose root is a div extending
React.HTMLAttributes<HTMLDivElement>, plus:
label (default "Install app"), guideLabel (default "How to install"),
storageKey (default "zyeon:pwa-install-dismissed"),
remindAfterDays (default 30; 0 never hides; negatives clamp to 0; anything
that is not a finite number — NaN, Infinity, null, a string — falls back to
the default rather than to "hide forever"),
guide ("ios" | "browser-menu" | "none", default: sniff the user agent),
onOutcome (outcome: "accepted" | "dismissed" | "unavailable") => void.
- Export useInstallPrompt(options) returning
{ status, guide, steps, promptInstall, snooze, reset }, where status is
"unavailable" | "installed" | "snoozed" | "ready" | "pending" | "guide".
The component is a thin renderer over this hook.
- Declare the BeforeInstallPromptEvent shape LOCALLY (prompt(), userChoice,
platforms) and reach navigator.standalone / navigator.getInstalledRelatedApps
through a cast. Never `declare global` — it collides the day TypeScript ships
its own definition, and this file has to compile in the consumer's project.
Behavior
- Capture at MODULE scope, not in an effect. beforeinstallprompt fires once,
early, and routinely before hydration — a listener registered in useEffect
hears nothing and the button silently never appears. Register the window
listener when the module is evaluated, call preventDefault() (otherwise the
browser keeps its own install UI and the button is decoration), park the
event in module state, and let components subscribe to that state through
useSyncExternalStore. Consequence, and it is the design: the window listener
is never removed. It is registered exactly once per document however many
buttons mount and unmount. Components clean up their own store subscription.
- Render nothing rather than a control that cannot act:
* installed — matchMedia("(display-mode: standalone), (display-mode:
minimal-ui), (display-mode: window-controls-overlay)"), navigator.standalone
for an iOS home-screen launch, and a guarded getInstalledRelatedApps()
probe. Do NOT include (display-mode: fullscreen): a plain tab put into
fullscreen with F11 matches it.
* refused recently — see the cooldown below.
* no prompt and no known manual route — stay silent.
- prompt() is honoured once per event. Take the event OUT of module state
before awaiting prompt(), so a second press — or a second button on the page,
or two clicks in one tick — finds nothing and can never reach it. While the
dialog is open the status is "pending": the same button, aria-busy, spinner,
label "Waiting for the browser…", clicks ignored.
- Once the prompt is spent the control must still do something. It becomes a
disclosure (aria-expanded + aria-controls) for the browser-menu steps rather
than a button that swallows presses. Same disclosure on iOS, where WebKit has
no beforeinstallprompt at all: Share -> Add to Home Screen, three steps.
Detect iOS from /iphone|ipad|ipod/i OR a "Macintosh" UA with maxTouchPoints
> 1 — iPadOS 13+ reports a desktop user agent.
- userChoice "dismissed" writes Date.now() to localStorage[storageKey] and the
button stays away for remindAfterDays; the timestamp is re-read on mount so
the silence survives a reload and, via the "storage" event, reaches other
tabs. userChoice "accepted" latches installed: offering an install to someone
who just accepted one is nagging, whether or not appinstalled has landed yet.
A prompt() that throws is not a refusal — no cooldown, fall through to the
manual steps. Wrap every localStorage access in try/catch: private mode
throws on access itself.
- SSR and hydration: getServerSnapshot returns a snapshot with no storage keys
read, and a key that has not been read yet renders nothing. Server and first
client render therefore agree, and a snoozed button never flashes for a frame
before hiding. Never read Date.now(), navigator or localStorage during
render — the store puts `now` and the detected platform into the snapshot.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the install
CTA, bg-card + border + hover:bg-accent for the disclosure, bg-card panel
with text-muted-foreground steps. No hardcoded colors, dark mode is free.
- cn() merges the consumer className onto the root; rest props spread there.
- Transitions carry motion-reduce:transition-none and the pending spinner
motion-reduce:animate-none; opening the steps is a mount, not an animation,
so the control keeps working with motion off.
- Accessibility: type="button" everywhere, aria-busy while pending,
aria-expanded on the disclosure — plus aria-controls only while the panel is
actually mounted, since an IDREF pointing at nothing is an authoring error
and aria-expanded already carries the closed state — an ordered list for the
steps, focus-visible ring, icons aria-hidden.
- Width safety: cap the steps panel with max-w-[min(20rem,100%)] and let the
labels wrap (wrap-anywhere + min-w-0) so a translated label cannot push the
layout past the viewport.
Customization levers
- Cooldown: remindAfterDays is the whole nagging policy. 0 = ask every time
the browser offers, 30 = the default, 365 = practically once.
- Scope: give each surface its own storageKey when a refusal in the footer
should not silence the one in onboarding.
- Silence: guide="none" removes the manual fallback entirely — a good choice
when your audience is desktop Chromium and the written steps would be noise.
guide="ios" / "browser-menu" force a route when the server already knows the
platform from the request's user agent.
- Copy: label, guideLabel, the exported INSTALL_GUIDES record (title + steps
per route) and the one inline "Waiting for the browser…" pending label are
the whole translation surface.
- Shape: useInstallPrompt() gives you the same state machine with no markup —
render the CTA as a banner, a menu row or a toast without forking the logic.
- Earliest capture: importing the module in the root layout (rather than only
inside a lazily-loaded route) is what buys the earliest possible listener.Concepts
- Module-scope capture — the listener lives with the module, not with a component instance, because
beforeinstallpromptfires once and usually before the tree that wants it exists. A button that mounts a second late still finds the event waiting for it; the same listener registered inuseEffecthears nothing at all. preventDefault()as a transfer of ownership — the browser only steps back from its own install affordance when the event is cancelled. Skip it and the component becomes decoration next to the browser's own UI.- Capability-gated rendering — installed, recently refused, or simply not installable each resolve to nothing on screen. A control that is visible is a control that can act; the component would rather be absent than be pressed for no result.
- One-shot prompt — the deferred event is taken out of the shared state before
prompt()is awaited, so concurrent presses and second buttons cannot reach an API the browser honours exactly once. - Dismissal cooldown — a refusal is a timestamp in
localStorage, andremindAfterDaysturns that timestamp into silence. Re-read on mount and mirrored across tabs through thestorageevent. - Manual fallback as real behaviour — where no prompt exists (iOS, or after the one prompt is spent) the button becomes a disclosure for written steps. It is still a button that does something when pressed, which is what separates a fallback from a dead control.