A Web App Manifest is a JSON file of application metadata that turns a tab into an installable app. It defines two things: identity (what this app is to the system) and the presentation of the installed copy - name, icons, colors, display mode. While the site is open as an ordinary page, the manifest barely affects UX; its role emerges at the moment of installation and launch from the home screen.
The key and often misunderstood field is id: a stable identifier of the installation. If you ever change id, the system treats it as a different app and offers to install it again, next to the old one. start_url is the launch point, the page that opens when the icon is tapped (adding a marker like ?source=pwa for analytics helps). scope is the boundary of the app-like context: while the user moves within scope they stay in the app shell, and stepping outside it lands them in a regular browser. This is not the same as the Service Worker scope, though in practice the two are aligned.
Icons deserve attention. Besides ordinary PNGs of the right size, provide a maskable icon with purpose: maskable: the platform applies its own mask (a circle, a rounded square), and the important content must fit inside the safe zone - a circle with a radius of 40% of the size, or the system will clip the corners of the logo. Real screenshots with correct sizes, type and form_factor turn the system install dialog into a showcase, while a mistake in MIME or dimensions makes the browser silently ignore the resource.
A valid manifest alone does not make installation right. Installation is a result of value, not a modal on the very first visit: someone who has not yet understood why they need the app will close the window and remember the annoyance. It is wiser to show the invitation after the user has gotten value - created a few notes, returned a second time. And crucially: the site must remain fully useful without installation, because most people will never install it.
Install criteria and UI differ across browsers, and there is no single standard mechanism. Before showing its own invitation, Chromium may fire the beforeinstallprompt event. Catching it, you call preventDefault(), store the event, and show your own button at the right moment. On click you invoke the stored event: prompt() shows the system dialog, and userChoice returns a promise with an outcome - accepted or dismissed. The event is one-shot: after prompt() it cannot be called again.
On iOS it is different: there is no beforeinstallprompt, and the user adds the app through the system Share - Add to Home Screen menu. So a button based on beforeinstallprompt alone simply will not appear on an iPhone, and you cannot rely on it as the only path. A cross-platform solution: the event arrived - show your button; it did not (Safari, or already installed) - hide it or give a short iOS instruction. The display-mode: standalone media query helps confirm the app is running in installed form.
Before release it is worth running an installability audit: the manifest is served with the correct Content-Type and linked via link rel=manifest, there are icons of the required sizes plus maskable, start_url is inside scope, id is stable, a Service Worker is registered, the page is on HTTPS. The telling failure is silent: a team changes start_url or id between releases, for some users the app detaches from the home screen or duplicates, and the logs show nothing, because technically it is just a new installation.
{
"id": "/notes/",
"name": "Offline Notes",
"short_name": "Notes",
"description": "Notes that work offline",
"start_url": "/notes/?source=pwa",
"scope": "/notes/",
"display": "standalone",
"background_color": "#07100f",
"theme_color": "#087f56",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/shots/wide.png", "sizes": "1280x720",
"type": "image/png", "form_factor": "wide" }
],
"shortcuts": [
{ "name": "New note", "url": "/notes/new" }
]
}let deferredInstall;
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
deferredInstall = event;
installButton.hidden = false;
});
installButton.onclick = async () => {
if (!deferredInstall) return;
deferredInstall.prompt();
const choice = await deferredInstall.userChoice;
deferredInstall = null;
installButton.hidden = true;
analytics.track('install_choice', { outcome: choice.outcome });
};