PatternsMobile & PWA
Install Prompt Strategies
Optimize A2HS (Add to Home Screen) prompts for maximum conversion
Add to Home Screen (A2HS) prompts encourage users to install your PWA. Poor implementation annoys users; good implementation increases installs by 3-5x.
PWA Installation Requirements
manifest.json
{
"name": "My Awesome App",
"short_name": "MyApp",
"description": "An awesome progressive web app",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"orientation": "portrait",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshot1.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/screenshot2.png",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide"
}
]
}Service Worker
// sw.js - Must be registered
self.addEventListener('install', (event) => {
console.log('Service Worker installed');
});
self.addEventListener('fetch', (event) => {
// Handle fetch events
});HTTPS
✅ HTTPS required (except localhost)
❌ HTTP won't workCapturing beforeinstallprompt
// utils/install-prompt.ts
let deferredPrompt: any = null;
export function setupInstallPrompt() {
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent default mini-infobar
e.preventDefault();
// Store for later use
deferredPrompt = e;
console.log('Install prompt available');
// Dispatch custom event
window.dispatchEvent(new Event('install-available'));
});
// Track successful installation
window.addEventListener('appinstalled', () => {
console.log('PWA installed successfully');
deferredPrompt = null;
// Analytics
trackEvent('pwa_installed');
// Thank user
showThankYouMessage();
});
}
export async function showInstallPrompt(): Promise<boolean> {
if (!deferredPrompt) {
console.log('Install prompt not available');
return false;
}
// Show native prompt
deferredPrompt.prompt();
// Wait for user response
const { outcome } = await deferredPrompt.userChoice;
console.log(`Install prompt ${outcome}`);
// Track outcome
trackEvent('install_prompt_response', { outcome });
// Clear prompt
deferredPrompt = null;
return outcome === 'accepted';
}
export function isInstallAvailable(): boolean {
return deferredPrompt !== null;
}Smart Prompt Timing
Wrong: Immediate Prompt
// ❌ BAD: Annoys users
window.addEventListener('load', () => {
showInstallPrompt();
});Right: Contextual Prompt
// ✅ GOOD: Show after user engagement
class InstallPromptManager {
private interactions = 0;
private timeOnSite = 0;
private startTime = Date.now();
constructor() {
this.trackEngagement();
}
private trackEngagement() {
// Track interactions
['click', 'scroll', 'keypress'].forEach(event => {
document.addEventListener(event, () => {
this.interactions++;
this.checkPromptCriteria();
});
});
// Track time on site
setInterval(() => {
this.timeOnSite = (Date.now() - this.startTime) / 1000;
this.checkPromptCriteria();
}, 5000);
}
private checkPromptCriteria() {
// Show prompt when user is engaged
if (
this.interactions >= 5 &&
this.timeOnSite >= 30 &&
!hasSeenPromptRecently()
) {
this.showPrompt();
}
}
private async showPrompt() {
const accepted = await showInstallPrompt();
if (!accepted) {
// Don't show again for 7 days
localStorage.setItem('last-prompt-shown', Date.now().toString());
}
}
}
function hasSeenPromptRecently(): boolean {
const lastShown = localStorage.getItem('last-prompt-shown');
if (!lastShown) return false;
const daysSince = (Date.now() - parseInt(lastShown)) / (1000 * 60 * 60 * 24);
return daysSince < 7;
}Contextual Prompts
After Feature Use
// Show after user completes action
async function saveDocument() {
await saveToServer();
// User just experienced value
if (isInstallAvailable() && !hasSeenPromptRecently()) {
showInstallBanner({
message: 'Save documents offline! Install the app',
context: 'after-save',
});
}
}Before Limited Feature
// Encourage install to unlock feature
function accessPremiumFeature() {
if (!isPWAInstalled()) {
showInstallBanner({
message: 'Install the app to access offline mode',
cta: 'Install App',
context: 'feature-gate',
});
} else {
// Allow access
showPremiumFeature();
}
}Custom Install Banner
// components/InstallBanner.tsx
import { useState, useEffect } from 'react';
import { isInstallAvailable, showInstallPrompt } from '@/utils/install-prompt';
export function InstallBanner() {
const [show, setShow] = useState(false);
useEffect(() => {
const handleInstallAvailable = () => {
// Show banner after criteria met
setTimeout(() => {
if (isInstallAvailable() && !hasSeenPromptRecently()) {
setShow(true);
}
}, 30000); // 30 seconds
};
window.addEventListener('install-available', handleInstallAvailable);
return () => window.removeEventListener('install-available', handleInstallAvailable);
}, []);
const handleInstall = async () => {
const accepted = await showInstallPrompt();
if (accepted || !isInstallAvailable()) {
setShow(false);
}
};
const handleDismiss = () => {
setShow(false);
localStorage.setItem('last-prompt-shown', Date.now().toString());
// Track dismissal
trackEvent('install_banner_dismissed');
};
if (!show) return null;
return (
<div className="install-banner">
<div className="install-content">
<img src="/icon-192x192.png" alt="App icon" className="app-icon" />
<div className="install-text">
<h3>Install My App</h3>
<p>Get quick access and work offline</p>
</div>
</div>
<div className="install-actions">
<button onClick={handleInstall} className="install-button">
Install
</button>
<button onClick={handleDismiss} className="dismiss-button">
✕
</button>
</div>
</div>
);
}iOS Installation Guide
// components/IOSInstallPrompt.tsx
import { useState, useEffect } from 'react';
export function IOSInstallPrompt() {
const [show, setShow] = useState(false);
useEffect(() => {
// Detect iOS Safari
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isInSafari = !!(window as any).safari;
if (isIOS && !isStandalone && isInSafari) {
// Show instructions after engagement
setTimeout(() => {
setShow(true);
}, 30000);
}
}, []);
if (!show) return null;
return (
<div className="ios-install-prompt">
<div className="ios-install-content">
<h3>Install this app</h3>
<p>Tap the share button below, then select "Add to Home Screen"</p>
<div className="ios-instructions">
<div className="instruction">
<span className="step">1</span>
<p>Tap <span className="icon">⎙</span> (Share button)</p>
</div>
<div className="instruction">
<span className="step">2</span>
<p>Select "Add to Home Screen"</p>
</div>
<div className="instruction">
<span className="step">3</span>
<p>Tap "Add"</p>
</div>
</div>
<button onClick={() => setShow(false)}>Got it</button>
</div>
</div>
);
}A/B Testing Prompts
// Test different prompt strategies
type PromptVariant = 'immediate' | 'delayed' | 'contextual';
class InstallPromptTest {
private variant: PromptVariant;
constructor() {
// Assign variant (33% each)
const rand = Math.random();
if (rand < 0.33) {
this.variant = 'immediate';
} else if (rand < 0.66) {
this.variant = 'delayed';
} else {
this.variant = 'contextual';
}
this.runVariant();
}
private runVariant() {
switch (this.variant) {
case 'immediate':
window.addEventListener('load', this.showPrompt);
break;
case 'delayed':
setTimeout(this.showPrompt, 30000);
break;
case 'contextual':
this.trackEngagementAndPrompt();
break;
}
}
private async showPrompt() {
const accepted = await showInstallPrompt();
// Track results
trackEvent('install_prompt_shown', {
variant: this.variant,
accepted,
});
}
private trackEngagementAndPrompt() {
// Show after user completes key action
document.addEventListener('user-saved-document', this.showPrompt);
}
}Analytics
// Track install funnel
export class InstallAnalytics {
trackPromptAvailable() {
trackEvent('install_prompt_available');
}
trackPromptShown(context: string) {
trackEvent('install_prompt_shown', { context });
}
trackPromptAccepted() {
trackEvent('install_prompt_accepted');
}
trackPromptDismissed() {
trackEvent('install_prompt_dismissed');
}
trackInstalled() {
trackEvent('pwa_installed');
}
// Calculate conversion rate
getConversionRate() {
const shown = getEventCount('install_prompt_shown');
const installed = getEventCount('pwa_installed');
return shown > 0 ? (installed / shown) * 100 : 0;
}
}Best Practices
- Wait for engagement: 30+ seconds, 5+ interactions
- Explain value: Clear benefit of installing
- Contextual timing: After user experiences value
- Dismissible: Easy to close, don't be annoying
- Once per session: Don't spam
- Cooldown period: 7+ days between prompts
- iOS instructions: Different flow for Safari
- Track everything: Measure conversion rates
- A/B test: Find optimal strategy
- Respect dismissals: Don't show again soon
Measuring Success
// Installation metrics
export function trackInstallMetrics() {
// Check if installed
if (window.matchMedia('(display-mode: standalone)').matches) {
trackEvent('session_in_pwa');
} else {
trackEvent('session_in_browser');
}
// Track conversion funnel
const funnel = {
promptsShown: getEventCount('install_prompt_shown'),
accepted: getEventCount('install_prompt_accepted'),
dismissed: getEventCount('install_prompt_dismissed'),
installed: getEventCount('pwa_installed'),
};
console.log('Install Funnel:', funnel);
return {
conversionRate: (funnel.installed / funnel.promptsShown) * 100,
acceptanceRate: (funnel.accepted / funnel.promptsShown) * 100,
dismissalRate: (funnel.dismissed / funnel.promptsShown) * 100,
};
}Common Pitfalls
❌ Immediate prompt: Annoys new users
✅ Wait for engagement signals
❌ No context: "Install" with no reason
✅ Explain clear benefits
❌ Persistent: Shows repeatedly
✅ Cooldown periods, respect dismissals
❌ No iOS support: Safari users confused
✅ Show iOS-specific instructions
❌ No tracking: Can't optimize
✅ Track funnel, measure conversion
Install prompts should feel helpful, not intrusive—wait for the right moment and explain the value!