A sheet of platform-aware instructions for installing a web app to the home screen.
Loading...
"use client";import { useState } from "react";import { AddToHomescreen } from "@/components/ui/add-to-homescreen";import { Button } from "@/components/ui/button";/** * No platform or browser override: this shows the recipe for the browser you * are reading in. On desktop Chrome that is the address-bar install pill, with * the arrow pointing up at it. */export const AddToHomescreenDemo = () => { const [open, setOpen] = useState(false); return ( <> <Button onClick={() => setOpen(true)} variant="outline"> Add to home screen </Button> <AddToHomescreen appName="Ledger" onOpenChange={setOpen} open={open} /> </> );};
There is no browser API that tells you how to install a web app, so the component sniffs the user agent and picks a set of instructions. appName is the only required prop.
What each browser gets
Three tiers, resolved in order.
1. The browser's own prompt. On Chromium, beforeinstallprompt offers a real one-tap install, so the sheet shows an Install button and no instructions. It only fires if your site meets Chromium's install criteria, which still require a service worker with a fetch handler; a manifest alone is not enough. The event is non-standard and Chromium-only, so treat it as an enhancement, never the plan.
2. Written instructions, for everything that can be installed by hand.
3. A copy-link panel, where there is no install path at all.
Browser
Control
Sheet edge
Arrow
iOS Safari
Share, bottom toolbar (... first in Compact)
bottom
down
iOS Chrome, Edge, Firefox
Share, beside the address bar
bottom
none
Android Chrome, Edge, Samsung
menu, top right, then Install app
top
up
Android Firefox
menu, top right
top
up
Desktop Chrome, Edge
install pill at the end of the address bar
top
up
macOS Safari (Sonoma 14+)
File → Add to Dock
top
up
Desktop Firefox
no install path
n/a
none
In-app browsers
open in the system browser
top
none
Arrows
An arrow is drawn only where the control's position is knowable. iOS Chrome keeps Share beside an address bar the reader can move to either end, so that recipe names the landmark and draws nothing.
showArrow can only remove an arrow. It cannot add one where the position is unknown, so the component cannot be configured into pointing at the wrong place.
Rendering
The component is controlled: pass open and onOpenChange. It deliberately does not open itself, because an install prompt that hijacks a first page load is the reason people dismiss them unread.
It renders nothing until an effect has resolved the platform, so the server and the first client render agree and no wrong-platform instructions flash on screen. It also renders nothing when the app is already installed, detected through navigator.standalone on iOS and (display-mode: standalone) everywhere else.
appIconUrl shows the icon that will land on the home screen. Optional, but worth passing.
Detection
The user agent is matched against navigator.userAgentData.brands first, then the legacy navigator.userAgent string, which Chromium freezes.
In-app webviews (Facebook, Instagram, Threads, LinkedIn, X) are checked first, because they all masquerade as Safari or Chrome. None can install to the home screen, so they get an open-in-browser recipe.
Anything the component cannot place falls through to a short explanation and the page URL beside a copy button, never a blank panel.
Overrides
platform and browser pin the instructions instead of sniffing them, so docs, tests, and previews can show a variant deterministically. Both also skip the already-installed check and suppress the native install tier, so a pinned demo keeps showing the recipe it is demonstrating.
steps replaces the instructions entirely, and suppresses the arrow with them. messages overrides the built-in English copy, including the install button label. showArrow turns the pointer off.
Loading...
"use client";import { useState } from "react";import { AddToHomescreen } from "@/components/ui/add-to-homescreen";import type { AddToHomescreenBrowser, AddToHomescreenPlatform,} from "@/components/ui/add-to-homescreen";import { Button } from "@/components/ui/button";const VARIANTS: { browser: AddToHomescreenBrowser; label: string; platform: AddToHomescreenPlatform;}[] = [ { browser: "safari", label: "iOS Safari", platform: "ios" }, { browser: "chrome", label: "iOS Chrome", platform: "ios" }, { browser: "chrome", label: "Android Chrome", platform: "android" }, { browser: "chrome", label: "Desktop Chrome", platform: "desktop" }, { browser: "safari", label: "macOS Safari", platform: "desktop" }, { browser: "instagram", label: "In-app browser", platform: "ios" }, { browser: "firefox", label: "Desktop Firefox", platform: "desktop" },];/** * Every recipe, pinned. Note which ones draw an arrow: only those whose control * sits somewhere the page can actually know. iOS Chrome keeps Share beside an * address bar the reader may have moved to either end, so it points at nothing * and says where to look instead. */export const AddToHomescreenVariants = () => { const [active, setActive] = useState<number | null>(null); const variant = active === null ? null : VARIANTS[active]; return ( <div className="flex flex-wrap items-center justify-center gap-2"> {VARIANTS.map((item, index) => ( <Button key={item.label} onClick={() => setActive(index)} size="sm" variant="outline"> {item.label} </Button> ))} {variant && ( <AddToHomescreen appName="Ledger" browser={variant.browser} key={variant.label} onOpenChange={(next) => !next && setActive(null)} open platform={variant.platform} /> )} </div> );};
Accessibility
The instructions are a real <ol>, so a screen reader announces the step count and position. The step glyphs and the arrow are aria-hidden, and the numbers are decorative: each step's text carries the whole instruction on its own.
Focus trapping, Escape, and the labelled dialog come from the underlying sheet. The arrow's bounce is a CSS animation, already covered by the global reduced-motion rule in @blode/ui.