Feedback

InstallPrompt

A quiet offer to install the web app, shown only where the browser can install it (Chromium on Android and desktops), never while it already runs installed, and not again for two weeks after Not now. Install opens the browser's own install dialog. It renders nothing otherwise, so it can sit in a layout unconditionally. The browser's install event is caught as soon as the module loads, so an offer is never missed because the page mounted late. When Install or Not now takes the card away while it has focus, focus goes back where it was before the card, or to the next control on the page when that is gone.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/root.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/install-prompt#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/install-prompt

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/install-prompt#v1.2.13 ourostack/ouro-ui/install-prompt-tests#v1.2.13
import { InstallPrompt } from "@/components/ouro/install-prompt/install-prompt";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<InstallPrompt className="mt-6" />

Use instead when

  • Use useInstallPrompt instead to offer installing from a control of your own, such as a menu item.

Props

Type: InstallPromptProps.

className
Type: string
Layout only: margins, width (mt-6, max-w-md). Appearance classes warn in development.
description
Type: ReactNode
Default: "Add it to your home screen or dock and open it like any other app."
What installing gives the person.
dismissLabel
Type: string
Default: "Not now"
The label of the button that puts the offer away.
headingLevel
Type: 2 | 3 | 4 | 5 | 6
Default: 2
The title's heading level.
installLabel
Type: string
Default: "Install"
The label of the button that opens the browser's install dialog.
media
Type: ReactNode
A picture across the top, such as the app on a phone.
onInstall
Type: ((outcome: "accepted" | "dismissed") => void)
Called with how the person answered the browser's install dialog.
remindAfterDays
Type: number
Default: 14
Days before offering again after Not now.
storageKey
Type: string
Default: "ouro-install-dismissed"
The localStorage key that keeps when the person said Not now.
title
Type: string
Default: "Install the app"
The offer's title.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • onAccept: Not a prop. Ouro UI says onInstall: <InstallPrompt onInstall={(outcome) => …} />.
  • open: Not a prop. The offer shows itself when the browser can install the app; there is nothing to open.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: InstallPrompt merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

useInstallPrompt()

Whether the browser offers to install this web app, and the means to offer it at a good moment rather than when the browser chooses: install() opens the browser's own install dialog, dismiss() puts the offer away for two weeks. It is available only where the browser fires beforeinstallprompt (Chromium on Android and desktops; not Safari or Firefox), and never while the app already runs installed.

import { useInstallPrompt } from "@/lib/ouro/use-install-prompt";

Example

const { available, install } = useInstallPrompt();
if (available) return <Button onClick={install}>Install the app</Button>;

Use instead when

  • Use InstallPrompt instead for the whole offer, a card with Install and Not now.

Parameters

{ remindAfterDays = 14, storageKey = "ouro-install-dismissed" }
Type: InstallPromptOptions
{}

Returns

InstallPromptState

Types

InstallPromptOptions

Options for useInstallPrompt.

interface InstallPromptOptions {
  /** Days to wait before offering again after the person says not now. @default 14 */
  remindAfterDays?: number | undefined;
  /** The `localStorage` key that keeps when the person last said not now. @default "ouro-install-dismissed" */
  storageKey?: string | undefined;
}

InstallPromptState

What useInstallPrompt returns.

interface InstallPromptState {
  /** Whether to offer installing now: the browser can install the app, it is not installed, and the person has not said not now lately. */
  available: boolean;
  /** Whether the app runs installed (standalone) or was installed from this page. */
  installed: boolean;
  /** Opens the browser's install dialog and resolves how the person answered. */
  install: () => Promise<"accepted" | "dismissed" | "unavailable">;
  /** Hides the offer and remembers it for `remindAfterDays`. */
  dismiss: () => void;
}

Claim sources