{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "install-prompt",
  "title": "Install Prompt",
  "description": "An install button for a web app that appears only when there is genuinely something to install, and knows the difference between an app that cannot be installed, one that can be installed but not by asking, and one somebody is already inside. Reach for it wherever the app is worth keeping rather than visiting: a documentation site or internal tool opened every day, a dashboard checked from a phone, a field-service, delivery, warehouse or point-of-sale app, a recipe, workout or practice timer, a reader, notes or journal app, a kiosk or presentation view, a game, a conference schedule, a ticket or boarding pass, and anything meant to keep working offline. Common asks it answers: \"pwa install button react\", \"beforeinstallprompt react\", \"add to home screen react\", \"install prompt component\", \"useInstallPrompt hook\", \"a2hs react\", \"react-pwa-install alternative\", \"shadcn install button\", \"custom pwa install button\", \"beforeinstallprompt not firing\", \"beforeinstallprompt never fires in react\", \"install button does not appear\", \"deferredPrompt is null\", \"prompt() NotAllowedError pwa\", \"detect if pwa is installed\", \"display-mode standalone react\", \"navigator.standalone ios\", \"ios add to home screen instructions\", \"safari pwa install no api\", \"appinstalled event react\", \"PWA インストールボタン react\", \"ホーム画面に追加 react\", \"beforeinstallprompt 発火しない\", \"インストールボタンが出ない\". Official shadcn/ui has nothing of the kind, and the figure is measured rather than remembered: its catalogue today lists 65 entries of which 62 are fetchable (code-block, questionnaire and trace 404) for 223,287 bytes of component source, and in all of it beforeinstallprompt, appinstalled, display-mode, standalone, navigator.standalone, manifest, pwa and even the bare words install and prompt are every one of them zero hits. So an agent asked for this writes it from scratch, and the version it writes has one bug in it that local testing will not show. beforeinstallprompt fires once, early, and never replays: Chromium dispatches it as soon as its own heuristics are satisfied, which on a warm cache is routinely before a React bundle has been parsed, let alone hydrated — so a listener added in useEffect is not late by a frame, it is late by the whole load. It passes in development, where the bundle arrives first, and fails in production, which is the single most common report about PWA install buttons: \"it never shows up on my machine, but it does on yours\". This component registers at module scope, moving the listener earlier by however long hydration takes, and exports earlyCaptureScript() — a few lines to inline in <head> ahead of the bundle — for the loads where nothing inside the bundle can be early enough, because the event has already fired by the time the bundle exists. The captured event is parked on window rather than in module state, so a page that somehow loads two copies of the file still shares one event instead of each copy believing there is nothing to install. Three more things separate it from the twenty-line version. Availability is four-valued — available, instructions, installed, unavailable — because \"cannot install\" and \"can install, but not by asking\" need different interfaces, and collapsing the second into the first is how an iPhone gets shown nothing at all on the platform where adding to the home screen matters most: iOS has no beforeinstallprompt at all, routes the whole thing through the share sheet, and so is given written steps instead — the same \"design the missing environment as the real one\" shape as share-button and fullscreen-button. Already-installed is read from matchMedia across all four installed display modes rather than standalone alone, because a manifest can ask for fullscreen or minimal-ui and an installed desktop app drawing into its own title bar reports window-controls-overlay — a check against standalone alone offers to install the app to somebody already running it — while iOS spells it navigator.standalone and has never implemented the media feature. And prompt() is honoured only during a user gesture and only once per event, so it is called as the first thing in the handler with nothing awaited in front of it: an await first, reading a flag or asking an API whether to offer this, is what turns a working button into NotAllowedError. The event is marked spent before anything is awaited, because a second click can land while userChoice is still unresolved. The first render is always the empty one, on the server and the client alike, so there is no hydration mismatch and nothing flashes in and back out again — reading matchMedia or the parked event during render instead is the same bug wearing a different hat. The component returns null whenever there is nothing to offer, so it can be left mounted where it belongs in the layout instead of being wrapped in a condition the page has to work out for itself. useInstallPrompt() is exported for a button of your own design, handing back status and promptToInstall(), with isRunningInstalled() and isIosLike() alongside it; onOutcome reports \"accepted\", \"dismissed\" or \"unavailable\" for analytics, and the manual steps are a prop, so they can be re-worded, translated or pointed at a different browser. It deliberately does not remember a refusal — whether somebody who said no yesterday should be asked today is a product decision with no safe default, and a shopping site and an internal tool want opposite answers — so record it through onOutcome, or compose it with announcement-bar's remembered dismissal. Within pulld it sits with the components that handle what the browser is doing behind the app: wake-lock-toggle holds a resource the browser can take back silently, network-status works out whether the connection is really there, geolocation-button handles a permission that can be refused for good, and this one handles the single capability the page cannot ask for twice. The instructions panel stays in the tree and is hidden with the attribute rather than left out, because aria-controls has to point at an element that exists; the toggle carries aria-expanded, both icons are aria-hidden, and the focus ring is focus-visible. Every colour is a shadcn token, so light and dark follow on their own, and its only dependency is lucide-react for the two icons.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/install-prompt.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Download, Share } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The event Chromium fires once it has decided a page is installable.\n *\n * It is not in lib.dom — the specification never shipped outside Chromium — so\n * `addEventListener(\"beforeinstallprompt\", ...)` hands you a plain `Event` with none of the three\n * members that matter. Declared locally rather than as a global augmentation, so installing this\n * file into an app that already declares it somewhere cannot collide.\n */\nexport interface BeforeInstallPromptEvent extends Event {\n  /** The install targets the browser would offer. Usually `[\"web\"]`, and empty on some desktops. */\n  readonly platforms: readonly string[]\n  /** Resolves once the person has answered the browser's own install dialog. */\n  readonly userChoice: Promise<{ outcome: \"accepted\" | \"dismissed\"; platform: string }>\n  /** Opens that dialog. Honoured only during a user gesture, and only once per event. */\n  prompt(): Promise<void>\n}\n\n/**\n * The property on `window` where a captured event is parked.\n *\n * It is a named constant because two separate programs have to meet on it: the snippet in `<head>`\n * (see `earlyCaptureScript`) and this module. A typo in either is not an error anywhere — it is a\n * button that silently never appears, on somebody else's machine.\n */\nexport const INSTALL_CAPTURE_KEY = \"__installPromptCapture\"\n\ninterface InstallCapture {\n  /** The most recent `beforeinstallprompt`, held until it is spent. */\n  event: BeforeInstallPromptEvent | null\n  /** Whether `appinstalled` has fired in this document. */\n  installed: boolean\n}\n\n/**\n * The snippet to inline in `<head>`, ahead of the application's own JavaScript.\n *\n * This is the part that decides whether any of the rest works, and it is the answer to the single\n * most common report about PWA install buttons — \"it never shows up on my machine, but it does on\n * yours\". `beforeinstallprompt` fires once, early, and does not replay: Chrome dispatches it as\n * soon as its own heuristics are satisfied, which on a warm cache is routinely before a React\n * bundle has been parsed, never mind hydrated. A listener added in `useEffect` is not late by a\n * frame, it is late by the whole load, and it will keep passing in development — where the bundle\n * arrives first — while failing in production. Registering at module scope (this file does, below)\n * moves it earlier by however long hydration takes, and that is enough on most loads. It is not\n * enough on all of them, and nothing inside the bundle can be: the fix has to run before the bundle\n * exists, which means a few lines of inline script.\n *\n * Next.js (app router), in `app/layout.tsx` inside `<head>`:\n *\n * ```tsx\n * <script dangerouslySetInnerHTML={{ __html: earlyCaptureScript() }} />\n * ```\n *\n * Vite and plain HTML, in `index.html` before the module script:\n *\n * ```html\n * <script>\n *   // paste the output of earlyCaptureScript() here\n * </script>\n * ```\n *\n * It is written against no framework and touches nothing but one property it owns, so it is safe\n * to ship whether or not this component is on the page. Leaving it out is supported — the module\n * scope listener still catches the common case — so treat it as the difference between \"usually\n * works\" and \"works\".\n */\nexport function earlyCaptureScript(): string {\n  const key = JSON.stringify(INSTALL_CAPTURE_KEY)\n  return (\n    `(function(w){var c=w[${key}]=w[${key}]||{event:null,installed:false};` +\n    `w.addEventListener(\"beforeinstallprompt\",function(e){e.preventDefault();c.event=e});` +\n    `w.addEventListener(\"appinstalled\",function(){c.event=null;c.installed=true})})(window)`\n  )\n}\n\nconst listeners = new Set<() => void>()\nlet capturing = false\n\n/** The shared parking space, created on first use. Null on the server, where there is no window. */\nfunction capture(): InstallCapture | null {\n  if (typeof window === \"undefined\") return null\n  const host = window as unknown as Record<string, InstallCapture | undefined>\n  const existing = host[INSTALL_CAPTURE_KEY]\n  if (existing) return existing\n  const fresh: InstallCapture = { event: null, installed: false }\n  host[INSTALL_CAPTURE_KEY] = fresh\n  return fresh\n}\n\nfunction emit(): void {\n  for (const listener of listeners) listener()\n}\n\n/**\n * Attaches the two window listeners, once per document.\n *\n * Safe to call repeatedly — the hook calls it on every mount — because the whole point is that the\n * listeners outlive any component. They are deliberately never removed: an unmounting button must\n * not throw away an event that will not be sent again, and two listeners on one window cost\n * nothing. The capture they write to is on `window` rather than in this module so that a page which\n * somehow loads two copies of this file (two bundles, a micro-frontend) still shares one event —\n * module state would give each copy its own, and the second would believe there is nothing to\n * install.\n */\nfunction startCapturing(): void {\n  if (capturing) return\n  const store = capture()\n  if (!store) return\n  capturing = true\n  window.addEventListener(\"beforeinstallprompt\", (event) => {\n    // Without this the browser shows its own install affordance as well, which is the thing an\n    // app renders its own button in order to place and time itself.\n    event.preventDefault()\n    store.event = event as BeforeInstallPromptEvent\n    emit()\n  })\n  window.addEventListener(\"appinstalled\", () => {\n    // The event is spent the moment the app is installed, and the browser will not send another.\n    store.event = null\n    store.installed = true\n    emit()\n  })\n}\n\n// At module scope on purpose — see `earlyCaptureScript`. This runs when the chunk is evaluated,\n// which is before hydration and therefore before any effect this component could schedule.\nstartCapturing()\n\n/**\n * The display modes a document is in when it is running as an installed app rather than in a tab.\n *\n * All four, not just `standalone`: a manifest can ask for `fullscreen` or `minimal-ui`, and an\n * installed desktop app that draws into its own title bar reports `window-controls-overlay`. A\n * check against `standalone` alone offers to install the app to somebody already inside it.\n */\nconst INSTALLED_DISPLAY_MODES = [\n  \"standalone\",\n  \"fullscreen\",\n  \"minimal-ui\",\n  \"window-controls-overlay\",\n] as const\n\n/**\n * Whether this document is already running as an installed app.\n *\n * Note what it does not answer: whether the app is installed. Those are different questions, and\n * only the first one is knowable from a browser tab. A person who installed the app yesterday and\n * is reading the site in Chrome today is in `display-mode: browser` like anybody else. Chromium\n * covers that case from the other side — it does not fire `beforeinstallprompt` for an app it has\n * already installed — so the button stays away regardless. iOS has no equivalent, which is the\n * honest limit of the `\"instructions\"` state below.\n */\nexport function isRunningInstalled(): boolean {\n  if (typeof window === \"undefined\") return false\n  // iOS exposes it as a property on navigator and has never implemented the media feature.\n  const nav = window.navigator as Navigator & { standalone?: boolean }\n  if (nav.standalone === true) return true\n  if (typeof window.matchMedia !== \"function\") return false\n  return INSTALLED_DISPLAY_MODES.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches)\n}\n\n/**\n * Whether this is an iOS-family browser, where the install event does not exist at all.\n *\n * Sniffing the user agent, which is normally the wrong instrument, and here is the only one: the\n * question is precisely \"which browser am I\", because the capability it stands in for — adding a\n * site to the home screen — is reachable from a menu this page cannot see, feature-detect or open.\n * Every browser on iOS is WebKit underneath and all of them route it through the share sheet, so\n * the branch is the platform and not the brand.\n *\n * The second test is iPadOS 13 and later, which reports itself as a Macintosh. A touchscreen is\n * what separates it from a real one: desktop Safari reports `maxTouchPoints` of 0.\n */\nexport function isIosLike(): boolean {\n  if (typeof navigator === \"undefined\") return false\n  const ua = navigator.userAgent\n  if (/iphone|ipod|ipad/i.test(ua)) return true\n  return /macintosh/i.test(ua) && navigator.maxTouchPoints > 1\n}\n\n/** What, if anything, there is to offer right now. */\nexport type InstallAvailability =\n  /** A captured event is in hand: a press opens the browser's install dialog. */\n  | \"available\"\n  /** The platform installs web apps but has no API for it — show the manual steps instead. */\n  | \"instructions\"\n  /** This document is already running as an installed app. */\n  | \"installed\"\n  /** Nothing to offer, and nothing to say about it. */\n  | \"unavailable\"\n\n/** How a press ended. `\"unavailable\"` covers a spent event and a dialog the browser refused to open. */\nexport type InstallOutcome = \"accepted\" | \"dismissed\" | \"unavailable\"\n\nexport interface UseInstallPromptOptions {\n  /**\n   * Report `\"instructions\"` on platforms that can install but cannot be asked. Pass `false` to\n   * treat those as `\"unavailable\"` and show nothing at all.\n   */\n  instructions?: boolean\n  /** Called once the person has answered the browser's dialog. */\n  onOutcome?: (outcome: InstallOutcome) => void\n}\n\nexport interface InstallPromptState {\n  /** What there is to offer. Always `\"unavailable\"` on the first render — see below. */\n  status: InstallAvailability\n  /**\n   * Opens the browser's install dialog and resolves with the answer.\n   *\n   * Call it directly from the click handler. See the comment in the body for why nothing may be\n   * awaited in front of it.\n   */\n  promptToInstall: () => Promise<InstallOutcome>\n}\n\n/**\n * Whether an install can be offered, and the one function that offers it.\n *\n * The first render always reports `\"unavailable\"`, on the server and on the client alike, and the\n * effect corrects it a frame later. That is the opposite default to the one a connection indicator\n * takes — there, assuming the worst would flash an offline banner at everybody — because here the\n * pessimistic answer is the one that renders nothing. Reading `matchMedia` or the parked event\n * during render instead would be a hydration mismatch on every page that server-renders, and\n * reading it in a `useState` initialiser is the same bug wearing a different hat.\n *\n * Three states and not two, because \"cannot install\" and \"can install, but not by asking\" need\n * different interfaces, and collapsing them is how an iPhone ends up being shown nothing at all on\n * the platform where adding to the home screen is most useful.\n */\nexport function useInstallPrompt(options: UseInstallPromptOptions = {}): InstallPromptState {\n  const { instructions = true, onOutcome } = options\n\n  const [status, setStatus] = React.useState<InstallAvailability>(\"unavailable\")\n\n  // Read through a ref so that passing the callback inline — which every caller does — does not\n  // tear the subscription down and build it up again on every render.\n  const onOutcomeRef = React.useRef(onOutcome)\n  onOutcomeRef.current = onOutcome\n\n  React.useEffect(() => {\n    function read() {\n      const store = capture()\n      const next: InstallAvailability =\n        store?.installed === true || isRunningInstalled()\n          ? \"installed\"\n          : store?.event\n            ? \"available\"\n            : instructions && isIosLike()\n              ? \"instructions\"\n              : \"unavailable\"\n      setStatus((previous) => (previous === next ? previous : next))\n    }\n\n    // Subscribed before the first read, so an event arriving in between is not dropped into the\n    // gap. Reading first and subscribing second is the ordinary version of this bug.\n    listeners.add(read)\n    startCapturing()\n    read()\n\n    // A document can enter an installed display mode without being reloaded — accepting the\n    // browser's dialog in a desktop window is the usual way — so the media query is watched rather\n    // than sampled once.\n    const media =\n      typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n        ? window.matchMedia(\"(display-mode: standalone)\")\n        : null\n    media?.addEventListener(\"change\", read)\n\n    return () => {\n      listeners.delete(read)\n      media?.removeEventListener(\"change\", read)\n    }\n  }, [instructions])\n\n  const promptToInstall = React.useCallback(async (): Promise<InstallOutcome> => {\n    const store = capture()\n    const event = store?.event\n    if (!store || !event) return \"unavailable\"\n\n    // Spent before anything is awaited, for two reasons that look like one. `prompt()` on an event\n    // that has already been prompted rejects, and a second click can land while `userChoice` is\n    // still unresolved — the dialog does not block this page's event loop. Published immediately\n    // after, so the button reflects the spent event even if the call below never resolves.\n    store.event = null\n    emit()\n\n    try {\n      // Called with nothing awaited in front of it. The browser honours `prompt()` only while the\n      // click that led here is still the active user gesture, so an `await` first — reading a flag,\n      // asking an API whether to offer this — is what turns a working button into NotAllowedError.\n      // Inside the `try` rather than ahead of it: a browser that throws from `prompt()` instead of\n      // returning a rejected promise would otherwise leave the click with an unhandled rejection.\n      await event.prompt()\n      const { outcome } = await event.userChoice\n      onOutcomeRef.current?.(outcome)\n      return outcome\n    } catch {\n      // A refused or already-spent prompt. Not reported as a dismissal: nobody declined anything.\n      onOutcomeRef.current?.(\"unavailable\")\n      return \"unavailable\"\n    }\n  }, [])\n\n  return { status, promptToInstall }\n}\n\nexport interface InstallPromptProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\">,\n    UseInstallPromptOptions {\n  /** Label of the install button. */\n  label?: React.ReactNode\n  /** Label of the button that reveals the manual steps. */\n  instructionsLabel?: React.ReactNode\n  /** The manual steps. Replace them to re-word, translate, or describe a different browser. */\n  steps?: React.ReactNode\n}\n\nconst BUTTON_CLASS =\n  \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n\nconst DEFAULT_STEPS = (\n  <ol className=\"m-0 list-decimal space-y-1 pl-4\">\n    <li>\n      Tap the <span className=\"font-medium text-foreground\">Share</span> button in the browser\n      toolbar.\n    </li>\n    <li>\n      Choose <span className=\"font-medium text-foreground\">Add to Home Screen</span>.\n    </li>\n    <li>\n      Tap <span className=\"font-medium text-foreground\">Add</span>.\n    </li>\n  </ol>\n)\n\n/**\n * The install button, rendered only when there is genuinely something to install.\n *\n * It returns `null` whenever there is not — already installed, not installable, or the event has\n * been spent — so it can be left mounted wherever it belongs in the layout instead of being\n * wrapped in a condition the page has to work out for itself. Because the first render is always\n * the empty one, nothing flashes in and back out again on a page that server-renders.\n *\n * The root stays a `<div>` in both states, so a page does not re-flow when a device turns out to\n * be the one that needs the steps rather than the button.\n *\n * Every colour is a shadcn token, so it follows light and dark. For a button of your own design,\n * take `useInstallPrompt` and leave this one out; all of the detection is in the hook.\n *\n * One thing it deliberately does not do: remember a refusal. Chromium may fire\n * `beforeinstallprompt` again on the next visit, and whether somebody who said no yesterday should\n * be asked today is a product decision with no safe default — a shopping site and an internal tool\n * want opposite answers. Use `onOutcome` to record it and stop rendering this, or compose it with\n * a dismissal that is already remembered, the way `announcement-bar` does.\n */\nexport function InstallPrompt({\n  label = \"Install app\",\n  instructionsLabel = \"Add to Home Screen\",\n  steps = DEFAULT_STEPS,\n  instructions = true,\n  onOutcome,\n  className,\n  ...props\n}: InstallPromptProps) {\n  const { status, promptToInstall } = useInstallPrompt({ instructions, onOutcome })\n  const [open, setOpen] = React.useState(false)\n  const panelId = React.useId()\n\n  if (status === \"installed\" || status === \"unavailable\") return null\n\n  return (\n    <div className={cn(\"inline-flex flex-col items-start gap-2\", className)} {...props}>\n      {status === \"available\" ? (\n        <button type=\"button\" onClick={() => void promptToInstall()} className={BUTTON_CLASS}>\n          <Download className=\"h-4 w-4\" aria-hidden=\"true\" />\n          {label}\n        </button>\n      ) : (\n        <>\n          <button\n            type=\"button\"\n            onClick={() => setOpen((previous) => !previous)}\n            aria-expanded={open}\n            aria-controls={panelId}\n            className={BUTTON_CLASS}\n          >\n            <Share className=\"h-4 w-4\" aria-hidden=\"true\" />\n            {instructionsLabel}\n          </button>\n          {/*\n            Rendered whether or not it is open, and hidden with the attribute rather than by being\n            left out of the tree: `aria-controls` above has to point at an element that exists, and\n            a panel that only appears once it is needed cannot be found by the control that names\n            it. No display utility is set on this element, so the attribute's own `display: none`\n            is what takes effect.\n          */}\n          <div\n            id={panelId}\n            hidden={!open}\n            className=\"max-w-xs rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground\"\n          >\n            {steps}\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
