Agent skill
mobile-pwa
Build Progressive Web Apps and mobile-first experiences. Covers service workers, offline support, push notifications, app manifest, installability, React Native basics, and mobile UX patterns.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/mobile-pwa
SKILL.md
Mobile & PWA Development
Expert guidance for building Progressive Web Apps and mobile-optimized experiences that feel native.
When to Use This Skill
- Building Progressive Web Apps (PWAs)
- Implementing offline-first functionality
- Adding push notifications to web apps
- Creating installable web applications
- Optimizing for mobile devices
- Building with React Native basics
- Implementing mobile UX patterns
Progressive Web App Fundamentals
Web App Manifest
json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A fast, reliable, and engaging app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"shortcuts": [
{
"name": "New Item",
"short_name": "New",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
],
"categories": ["productivity", "utilities"],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*", "video/*"]
}
]
}
}
}
HTML Head Configuration
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#3b82f6">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/icons/icon-180x180.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png">
<!-- Splash Screens for iOS -->
<link rel="apple-touch-startup-image" href="/splash/apple-splash-2048-2732.png"
media="(device-width: 1024px) and (device-height: 1366px)">
<title>My PWA</title>
</head>
Service Workers
Basic Service Worker Registration
typescript
// src/registerSW.ts
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) {
console.log('Service workers not supported');
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none'
});
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Every hour
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available, show update prompt
dispatchEvent(new CustomEvent('swUpdate', { detail: registration }));
}
});
});
return registration;
} catch (error) {
console.error('SW registration failed:', error);
return null;
}
}
// Handle update prompt
export function promptUserToUpdate(registration: ServiceWorkerRegistration): void {
if (confirm('New version available! Reload to update?')) {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
Complete Service Worker with Caching Strategies
typescript
// public/sw.js
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const IMAGE_CACHE = `images-${CACHE_VERSION}`;
// Assets to precache
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/offline.html',
'/icons/icon-192x192.png'
];
// Install event - precache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys
.filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE && key !== IMAGE_CACHE)
.map(key => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// Fetch event - serve from cache with strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip cross-origin requests
if (url.origin !== location.origin) return;
// API requests - Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
return;
}
// Images - Cache First with fallback
if (request.destination === 'image') {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
return;
}
// Static assets - Cache First
if (PRECACHE_ASSETS.includes(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// Other requests - Stale While Revalidate
event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
});
// Caching Strategies
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch {
return caches.match('/offline.html');
}
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
return cached || new Response(JSON.stringify({ error: 'Offline' }), {
headers: { 'Content-Type': 'application/json' }
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cached = await caches.match(request);
const fetchPromise = fetch(request)
.then(response => {
const cache = caches.open(cacheName);
cache.then(c => c.put(request, response.clone()));
return response;
})
.catch(() => cached);
return cached || fetchPromise;
}
// Handle skip waiting message
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Workbox Integration (Recommended)
typescript
// sw.ts (using Workbox)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
declare const self: ServiceWorkerGlobalScope;
// Precache assets from build
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// Cache Google Fonts
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts',
plugins: [
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 })
]
})
);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }),
new CacheableResponsePlugin({ statuses: [0, 200] })
]
})
);
// API calls - Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 5 })
]
})
);
// Navigation - Network First with offline fallback
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: 'navigations',
plugins: [
new CacheableResponsePlugin({ statuses: [200] })
]
}),
{
denylist: [/^\/_/, /\/api\//]
}
)
);
Push Notifications
Request Permission and Subscribe
typescript
// src/notifications.ts
interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
console.log('Notifications not supported');
return 'denied';
}
const permission = await Notification.requestPermission();
return permission;
}
export async function subscribeToPush(): Promise<PushSubscriptionData | null> {
try {
const registration = await navigator.serviceWorker.ready;
// Check for existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY;
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
}
// Send subscription to server
const subscriptionData: PushSubscriptionData = {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
}
};
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscriptionData)
});
return subscriptionData;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
if (!buffer) return '';
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
Handle Push in Service Worker
typescript
// In sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {
title: 'New Notification',
body: 'You have a new message'
};
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
image: data.image,
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
timestamp: Date.now()
},
actions: data.actions || [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' }
],
tag: data.tag || 'default',
renotify: true,
requireInteraction: data.requireInteraction || false
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => {
// Focus existing window if available
for (const client of windowClients) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Open new window
return clients.openWindow(url);
})
);
});
Offline Support
IndexedDB for Offline Data
typescript
// src/db.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface MyAppDB extends DBSchema {
items: {
key: string;
value: {
id: string;
title: string;
content: string;
synced: boolean;
updatedAt: number;
};
indexes: { 'by-synced': boolean };
};
pendingSync: {
key: number;
value: {
id?: number;
type: 'create' | 'update' | 'delete';
collection: string;
data: unknown;
timestamp: number;
};
};
}
let dbPromise: Promise<IDBPDatabase<MyAppDB>>;
export function getDB(): Promise<IDBPDatabase<MyAppDB>> {
if (!dbPromise) {
dbPromise = openDB<MyAppDB>('my-app-db', 1, {
upgrade(db) {
// Items store
const itemStore = db.createObjectStore('items', { keyPath: 'id' });
itemStore.createIndex('by-synced', 'synced');
// Pending sync store
db.createObjectStore('pendingSync', { keyPath: 'id', autoIncrement: true });
}
});
}
return dbPromise;
}
// Save item locally
export async function saveItem(item: MyAppDB['items']['value']): Promise<void> {
const db = await getDB();
await db.put('items', { ...item, synced: false, updatedAt: Date.now() });
// Queue for background sync
await db.add('pendingSync', {
type: item.id ? 'update' : 'create',
collection: 'items',
data: item,
timestamp: Date.now()
});
// Request background sync
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-items');
}
}
// Get all items
export async function getAllItems(): Promise<MyAppDB['items']['value'][]> {
const db = await getDB();
return db.getAll('items');
}
// Get unsynced items
export async function getUnsyncedItems(): Promise<MyAppDB['items']['value'][]> {
const db = await getDB();
return db.getAllFromIndex('items', 'by-synced', false);
}
Background Sync
typescript
// In sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-items') {
event.waitUntil(syncItems());
}
});
async function syncItems() {
const db = await openDB('my-app-db', 1);
const pendingItems = await db.getAll('pendingSync');
for (const item of pendingItems) {
try {
let endpoint = `/api/${item.collection}`;
let method = 'POST';
if (item.type === 'update') {
endpoint += `/${item.data.id}`;
method = 'PUT';
} else if (item.type === 'delete') {
endpoint += `/${item.data.id}`;
method = 'DELETE';
}
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data)
});
if (response.ok) {
// Remove from pending queue
await db.delete('pendingSync', item.id);
// Mark item as synced
if (item.type !== 'delete') {
const existing = await db.get(item.collection, item.data.id);
if (existing) {
await db.put(item.collection, { ...existing, synced: true });
}
}
}
} catch (error) {
console.error('Sync failed for item:', item, error);
// Will retry on next sync
}
}
}
Mobile UX Patterns
Touch-Friendly Components
tsx
// src/components/TouchButton.tsx
import { useState, useRef, type ReactNode } from 'react';
interface TouchButtonProps {
children: ReactNode;
onClick: () => void;
className?: string;
haptic?: boolean;
}
export function TouchButton({
children,
onClick,
className = '',
haptic = true
}: TouchButtonProps) {
const [isPressed, setIsPressed] = useState(false);
const touchStartTime = useRef(0);
const handleTouchStart = () => {
setIsPressed(true);
touchStartTime.current = Date.now();
// Haptic feedback
if (haptic && navigator.vibrate) {
navigator.vibrate(10);
}
};
const handleTouchEnd = () => {
setIsPressed(false);
// Only trigger if not a long press
if (Date.now() - touchStartTime.current < 500) {
onClick();
}
};
return (
<button
className={`
min-h-[44px] min-w-[44px]
touch-manipulation select-none
transition-transform duration-150
${isPressed ? 'scale-95' : 'scale-100'}
${className}
`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={() => setIsPressed(true)}
onMouseUp={handleTouchEnd}
onMouseLeave={() => setIsPressed(false)}
>
{children}
</button>
);
}
Pull-to-Refresh
tsx
// src/hooks/usePullToRefresh.ts
import { useState, useRef, useEffect } from 'react';
interface PullToRefreshOptions {
onRefresh: () => Promise<void>;
threshold?: number;
maxPull?: number;
}
export function usePullToRefresh({
onRefresh,
threshold = 80,
maxPull = 120
}: PullToRefreshOptions) {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleTouchStart = (e: TouchEvent) => {
if (container.scrollTop === 0) {
startY.current = e.touches[0].clientY;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (container.scrollTop > 0 || isRefreshing) return;
const currentY = e.touches[0].clientY;
const diff = currentY - startY.current;
if (diff > 0) {
e.preventDefault();
setPullDistance(Math.min(diff * 0.5, maxPull));
}
};
const handleTouchEnd = async () => {
if (pullDistance >= threshold && !isRefreshing) {
setIsRefreshing(true);
setPullDistance(threshold);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
setPullDistance(0);
}
} else {
setPullDistance(0);
}
};
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchend', handleTouchEnd);
return () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
};
}, [pullDistance, isRefreshing, onRefresh, threshold, maxPull]);
return { containerRef, pullDistance, isRefreshing };
}
// Usage
function MyList() {
const { containerRef, pullDistance, isRefreshing } = usePullToRefresh({
onRefresh: async () => {
await fetchData();
}
});
return (
<div ref={containerRef} className="h-full overflow-y-auto">
{/* Pull indicator */}
<div
className="flex justify-center items-center transition-transform"
style={{
height: pullDistance,
transform: `translateY(${pullDistance > 0 ? 0 : -50}px)`
}}
>
{isRefreshing ? (
<Spinner />
) : pullDistance > 60 ? (
<span>Release to refresh</span>
) : (
<span>Pull to refresh</span>
)}
</div>
{/* List content */}
{items.map(item => <ListItem key={item.id} {...item} />)}
</div>
);
}
Swipe Actions
tsx
// src/components/SwipeableItem.tsx
import { useState, useRef, type ReactNode } from 'react';
interface SwipeableItemProps {
children: ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
leftAction?: ReactNode;
rightAction?: ReactNode;
threshold?: number;
}
export function SwipeableItem({
children,
onSwipeLeft,
onSwipeRight,
leftAction,
rightAction,
threshold = 100
}: SwipeableItemProps) {
const [offset, setOffset] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
const startX = useRef(0);
const currentX = useRef(0);
const handleTouchStart = (e: React.TouchEvent) => {
startX.current = e.touches[0].clientX;
currentX.current = startX.current;
setIsAnimating(false);
};
const handleTouchMove = (e: React.TouchEvent) => {
currentX.current = e.touches[0].clientX;
const diff = currentX.current - startX.current;
// Limit swipe distance
const maxOffset = 150;
const limitedOffset = Math.sign(diff) * Math.min(Math.abs(diff), maxOffset);
// Only allow swipe if action exists
if ((diff > 0 && rightAction) || (diff < 0 && leftAction)) {
setOffset(limitedOffset);
}
};
const handleTouchEnd = () => {
setIsAnimating(true);
if (offset > threshold && onSwipeRight) {
onSwipeRight();
} else if (offset < -threshold && onSwipeLeft) {
onSwipeLeft();
}
setOffset(0);
};
return (
<div className="relative overflow-hidden">
{/* Background actions */}
<div className="absolute inset-0 flex">
{rightAction && (
<div className="flex-1 bg-green-500 flex items-center px-4">
{rightAction}
</div>
)}
{leftAction && (
<div className="flex-1 bg-red-500 flex items-center justify-end px-4">
{leftAction}
</div>
)}
</div>
{/* Swipeable content */}
<div
className={`relative bg-white ${isAnimating ? 'transition-transform duration-200' : ''}`}
style={{ transform: `translateX(${offset}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
</div>
);
}
React Native Basics
Setup and Structure
bash
# Create new React Native project with Expo
npx create-expo-app@latest my-app --template blank-typescript
cd my-app
# Install common dependencies
npx expo install react-native-gesture-handler react-native-reanimated
npx expo install @react-navigation/native @react-navigation/stack
npx expo install expo-status-bar expo-constants
Basic Navigation
tsx
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { StatusBar } from 'expo-status-bar';
import HomeScreen from './screens/HomeScreen';
import DetailsScreen from './screens/DetailsScreen';
export type RootStackParamList = {
Home: undefined;
Details: { itemId: string };
};
const Stack = createStackNavigator<RootStackParamList>();
export default function App() {
return (
<NavigationContainer>
<StatusBar style="auto" />
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: '#3b82f6' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: 'bold' }
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: 'My App' }}
/>
<Stack.Screen
name="Details"
component={DetailsScreen}
options={({ route }) => ({ title: `Item ${route.params.itemId}` })}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
Platform-Specific Code
tsx
// src/components/Button.tsx
import { Platform, TouchableOpacity, TouchableNativeFeedback, View, Text } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
}
export function Button({ title, onPress }: ButtonProps) {
const buttonContent = (
<View style={{
backgroundColor: '#3b82f6',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8
}}>
<Text style={{ color: 'white', fontWeight: 'bold', textAlign: 'center' }}>
{title}
</Text>
</View>
);
if (Platform.OS === 'android') {
return (
<TouchableNativeFeedback onPress={onPress}>
{buttonContent}
</TouchableNativeFeedback>
);
}
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{buttonContent}
</TouchableOpacity>
);
}
Install Prompt
Custom Install Prompt
tsx
// src/components/InstallPrompt.tsx
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function InstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
setIsVisible(true);
};
window.addEventListener('beforeinstallprompt', handler);
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsVisible(false);
}
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsVisible(false);
setInstallPrompt(null);
}
};
const handleDismiss = () => {
setIsVisible(false);
// Remember dismissal
localStorage.setItem('installPromptDismissed', Date.now().toString());
};
if (!isVisible) return null;
return (
<div className="fixed bottom-4 left-4 right-4 bg-white rounded-lg shadow-lg p-4 flex items-center gap-4 z-50 animate-slide-up">
<img src="/icons/icon-72x72.png" alt="App icon" className="w-12 h-12 rounded" />
<div className="flex-1">
<h3 className="font-semibold">Install Our App</h3>
<p className="text-sm text-gray-600">Get quick access from your home screen</p>
</div>
<button onClick={handleDismiss} className="text-gray-400 p-2">
Not now
</button>
<button
onClick={handleInstall}
className="bg-blue-500 text-white px-4 py-2 rounded-lg font-medium"
>
Install
</button>
</div>
);
}
Best Practices
Performance
-
Optimize First Paint
- Inline critical CSS
- Defer non-critical JavaScript
- Use skeleton screens
-
Touch Targets
- Minimum 44x44px touch targets
- Adequate spacing between targets
- Visual feedback on touch
-
Reduce Network Requests
- Bundle and minify assets
- Use service worker caching
- Lazy load images and routes
Accessibility
-
Zoom Support
- Never disable zoom
- Use relative units (rem, em)
- Test at 200% zoom
-
Reduced Motion
css@media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } -
Color Contrast
- 4.5:1 minimum for normal text
- 3:1 minimum for large text
- Don't rely on color alone
Testing PWAs
-
Lighthouse Audit
bashnpx lighthouse https://your-app.com --view -
PWA Checklist
- HTTPS enabled
- Valid manifest.json
- Service worker registered
- Offline page works
- Install prompt shows
- Push notifications work
Resources
Didn't find tool you were looking for?