Buttons

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, Download, LoaderCircle } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Platform typings
 *
 * `beforeinstallprompt` is not in lib.dom, and `navigator.standalone` /
 * `navigator.getInstalledRelatedApps` are not either. Everything is declared
 * LOCALLY and reached through a cast instead of through `declare global`: a
 * global augmentation collides the day a TypeScript release ships its own
 * definition, and this file has to compile inside every consumer's project.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/install-pwa-button.json

Prompt

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 beforeinstallprompt fires 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 in useEffect hears 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, and remindAfterDays turns that timestamp into silence. Re-read on mount and mirrored across tabs through the storage event.
  • 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.

On This Page