
Thumb First Platform
- 20 installs
- 13 repo stars
- Updated May 30, 2026
- kylezantos/thumb-first
Helps with ai & agent building tasks.
About
thumb-first-platform is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- thumb-first-platform
- AI & Agent Building
- AI-coding skill
Thumb First Platform by the numbers
- 20 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #10,459 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kylezantos/thumb-first --skill thumb-first-platformAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 13 |
| Last updated | May 30, 2026 |
| Repository | kylezantos/thumb-first ↗ |
What it does
Helps with ai & agent building tasks.
Files
Thumb-First · Platform
The technical-verification layer of the thumb-first suite. Where thumb-first-design decides what the mobile design should be (platform-agnostic judgment), this skill verifies that the implementation holds up on the actual device — objective defects with file:line and fixes, rated by severity.
Run /thumb-first for a combined design + platform review; use this skill directly for technical checks only.
---
Step 0 · Detect the Target (do this first)
The relevant defects are completely different for a web/PWA vs. a native app. Determine the target before doing anything else.
Sniff the repo:
- Web / PWA →
next.config.*,vite.config.*, anindex.html, a web appmanifest.json/manifest.webmanifest, a service worker, Tailwind/CSS. This is this skill's home turf — proceed to the principles and routing below. - Native →
react-native/expoinpackage.json,app.json/app.config.*(Expo), anios/+android/pair,*.xcodeproj/Package.swift/*.swift(SwiftUI/UIKit), orpubspec.yaml(Flutter). - Ambiguous or both (e.g. an Expo app with a web target) → ask which target to audit.
If the target is NATIVE — do not fake a web audit. This skill's checks (Safari quirks, viewport units, service workers, web manifest) do not apply. Instead:
1. Check for an installed native skill to hand off to — scan available skills / ~/.claude/skills/ for ones describing React Native, Expo, SwiftUI, native iOS/Android, or Flutter (e.g. react-native-design, building-native-ui, vercel-react-native-skills). 2. If one exists → hand off. Name it explicitly: "For the native technical pass, use <skill> — it covers native performance, lists, and platform APIs." Note that thumb-first-design still covers the design judgment for native, since that layer is platform-agnostic. 3. If none exists → say so plainly. Do not overreach:
thumb-first-platform covers mobile design judgment (via thumb-first-design) and web/PWA technical defects. It is not set up to run native-platform (React Native / SwiftUI / Flutter) performance or technical audits. For that, install a native-specific skill, or use platform-native tooling — Xcode Instruments, Android Studio Profiler, Flipper, or the React Native performance monitor.The design layer applies to every target. This technical layer is web/PWA-deep and native-aware-but-delegating. Be honest about that boundary.
---
Mobile Web & PWA — Core Principles
1. iOS Safari is the problem child
Most quirks and limitations live here. Always test on real iOS devices — simulators miss critical behaviors. Key pain points:
- PWAs require Safari for installation (Add to Home Screen)
- Push notifications only work when installed to home screen (iOS 16.4+)
- 7-day storage cap for non-installed web apps; 50MB cache limit; no background sync
- WebKit-only rendering (even Chrome on iOS uses WebKit)
2. Viewport units matter
Never use 100vh for full-height layouts on mobile — browser chrome causes overflow.
.hero { height: 100vh; height: 100svh; } /* svh for static elements (90% of cases) */
.menu { height: 100dvh; } /* dvh only for elements that should resize */dvh causes layout shifts — use sparingly.
3. Safe-area insets are required
Notches, Dynamic Islands, and home indicators are everywhere.
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">.header { padding-top: max(1rem, env(safe-area-inset-top)); }
.bottom-nav { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }4. Touch targets ≥ 44px
Apple HIG requires 44×44pt minimum; Google recommends 48×48dp. Never go smaller for tap targets.
5. Test on real devices
Chrome DevTools emulation lies — it doesn't simulate address-bar behavior, safe-area insets, touch-delay nuances, iOS Safari CSS bugs, or real mobile-hardware performance. Use real devices or BrowserStack/Sauce Labs.
---
Intake & Routing
What do you want to do?
1. Audit an existing mobile app/PWA for issues 2. Fix iOS Safari–specific problems 3. Set up PWA (manifest, service worker, icons) 4. Optimize mobile performance (Core Web Vitals) 5. Debug touch/gesture issues 6. Set up push notifications 7. Test mobile compatibility
Wait for the response, then read the matching workflow and follow it.
| Response | Workflow / Reference |
|---|---|
| 1 · "audit", "check", "review", "issues" | workflows/audit-mobile-pwa.md |
| 2 · "ios", "safari", "iphone", "apple" | workflows/fix-ios-issues.md |
| 3 · "pwa", "manifest", "service worker", "install" | workflows/setup-pwa.md |
| 4 · "performance", "slow", "vitals", "lcp", "cls" | workflows/optimize-performance.md |
| 5 · "touch", "gesture", "tap", "swipe", "scroll" | read references/touch-interactions.md, then fix |
| 6 · "push", "notification", "vapid" | read references/push-notifications.md, then implement |
| 7 · "test", "debug", "emulate" | workflows/test-mobile.md |
Intent-based routing (specific problem described):
- "100vh not working", "viewport", "height issue", "notch", "safe area", "dynamic island" →
references/viewport-layout.md - "images too big", "slow images" →
references/image-optimization.md - "rubber band", "bounce scroll" →
references/ios-safari-quirks.md
When run via `/thumb-first`: the umbrella invokes the audit path only (workflows/audit-mobile-pwa.md) and merges its defects into the combined report. The setup/push/perf workflows are build tasks — available on direct invocation, but outside the one-stop review.---
Verification Loop — After Every Change
1. Build succeeds? pnpm build 2. Lighthouse audit? Chrome DevTools → Lighthouse → Mobile + PWA 3. Real iOS device? Safari Web Inspector, or ngrok/localtunnel 4. Safe areas handled? Portrait and landscape, with and without notch
Report findings as: Build: ✓ · Lighthouse PWA: X/100 · iOS Safari: [specific issues].
---
Reference Index
All in references/:
| File | Contents |
|---|---|
| ios-safari-quirks.md | iOS Safari bugs, CSS workarounds, limitations |
| android-pwa.md | Android Chrome PWA behavior, WebAPK |
| viewport-layout.md | 100vh issues, dvh/svh/lvh, safe-area insets |
| pwa-manifest.md | manifest.json, icons, splash screens |
| service-workers.md | Caching strategies, offline support, Next.js integration |
| push-notifications.md | Web push, VAPID, iOS vs Android |
| touch-interactions.md | 300ms delay, touch-action, gestures |
| performance.md | Core Web Vitals (LCP/INP/CLS), mobile optimization |
| image-optimization.md | Next.js Image, responsive images, WebP/AVIF |
| responsive-patterns.md | Common mistakes, anti-patterns |
| testing-debugging.md | Lighthouse, testing tools, iOS debugging |
Workflow Index
| File | Purpose |
|---|---|
| audit-mobile-pwa.md | Comprehensive audit of a mobile app/PWA (the path /thumb-first calls) |
| fix-ios-issues.md | Fix iOS Safari–specific problems |
| setup-pwa.md | Configure a PWA from scratch |
| optimize-performance.md | Improve Core Web Vitals |
| test-mobile.md | Testing procedures |
---
Common Issues & Quick Fixes
/* 100vh overflow on mobile */ height: 100svh; /* or min-height */
/* 300ms tap delay */ button, a { touch-action: manipulation; }
/* rubber-band scroll */ html { overscroll-behavior: none; }<!-- safe area not working --> <meta name="viewport" content="..., viewport-fit=cover">
<!-- iOS status bar style --> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">PWA not installable: valid manifest.json · HTTPS · service worker registered · icons include 192×192 and 512×512.
<overview> Android provides significantly better PWA support than iOS through Chrome and Chromium-based browsers. Key advantages include automatic install prompts, WebAPK installation, and full Web Push support. Understanding Android PWA behavior helps build cross-platform experiences. </overview>
<browser_support>
Browser Support on Android
| Browser | Engine | PWA Support | Market Share |
|---|---|---|---|
| Chrome | Chromium | Full | ~65% |
| Samsung Internet | Chromium | Full | ~15% |
| Firefox | Gecko | Good | ~3% |
| Edge | Chromium | Full | ~2% |
| Opera | Chromium | Full | ~2% |
Key difference from iOS: Android browsers use their own engines. Chrome uses Chromium, Firefox uses Gecko. This is unlike iOS where all browsers must use WebKit. </browser_support>
<installation>
PWA Installation on Android
<feature name="Install Prompt"> Android Chrome shows an automatic install prompt when PWA criteria are met:
- Served over HTTPS
- Valid manifest.json with required fields
- Service worker with fetch handler
- User has engaged with the site
// Listen for install prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show your custom install button
showInstallButton();
});
// Trigger install when user clicks your button
installButton.addEventListener('click', async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
deferredPrompt = null;
});</feature>
<feature name="WebAPK"> On devices with Google Play Services (most Android devices), Chrome creates a WebAPK - a real Android package that:
- Appears in app drawer and settings like native app
- Has its own process and icon
- Receives OS-level updates
- Can be uninstalled normally
Samsung Internet also creates APKs through Galaxy Store infrastructure. </feature>
<feature name="Install Criteria"> Minimum requirements for installability:
- HTTPS (or localhost for development)
- manifest.json with:
nameorshort_namestart_urldisplay: 'standalone'or'fullscreen'or'minimal-ui'- Icons: 192x192 and 512x512 pixels
- Service worker with fetch event handler
</feature> </installation>
<push_notifications>
Push Notifications on Android
Full support - works in browser and installed PWA, foreground and background.
// Request permission
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// Get push subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to your server
await sendToServer(subscription);
}Key differences from iOS:
- Works without installing to home screen
- Background delivery works reliably
- No special requirements beyond HTTPS and service worker
</push_notifications>
<theming>
Android UI Theming
<feature name="Theme Color"> The theme_color in manifest.json colors the Android address bar and task switcher:
{
"theme_color": "#4285f4"
}Can also be set via meta tag (allows dynamic updates):
<meta name="theme-color" content="#4285f4">// Dynamic theme color
document.querySelector('meta[name="theme-color"]')
.setAttribute('content', isDark ? '#1a1a1a' : '#ffffff');</feature>
<feature name="Splash Screen"> Android automatically generates splash screen from manifest:
- Uses
background_colorfor splash background - Centers the icon from
iconsarray - Displays
namebelow icon
{
"name": "My App",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"icons": [
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}</feature>
<feature name="Display Modes">
{
"display": "standalone" // Most app-like
// Other options:
// "fullscreen" - no status bar (games)
// "minimal-ui" - some browser controls
// "browser" - regular browser tab
}</feature> </theming>
<differences_from_ios>
Key Differences from iOS
| Feature | Android | iOS |
|---|---|---|
| Install prompt | Automatic | Manual (Share → Add to Home) |
| Push notifications | Works everywhere | Only installed PWAs, iOS 16.4+ |
| Background sync | Supported | Not supported |
| Storage limits | More generous | 50MB cache limit |
| Storage persistence | Persists | 7-day cap if not installed |
| Browser engines | Multiple (Chromium, Gecko) | WebKit only |
| Install location | App drawer + home screen | Home screen only |
| Uninstall | Normal app uninstall | Delete from home screen |
Practical implications:
- Test iOS more thoroughly - it has more limitations
- Don't assume features that work on Android will work on iOS
- Consider iOS as the minimum baseline for PWA features
</differences_from_ios>
<samsung_internet>
Samsung Internet Specifics
Samsung Internet has ~15% market share on Android, especially on Samsung devices.
Key features:
- DeX mode support for desktop-like PWA experience
- Samsung Pay integration
- Biometric authentication API
- Ad blocking built-in (may affect analytics)
PWA differences:
- Uses Galaxy Store for PWA installation on some devices
- May have slight differences in Web API support
- Test specifically if targeting Samsung users
</samsung_internet>
<testing>
Testing Android PWAs
<method name="Chrome Remote Debugging"> 1. Enable Developer Options on Android 2. Enable USB Debugging 3. Connect via USB 4. Open chrome://inspect in desktop Chrome 5. Click "inspect" next to your app </method>
<method name="ADB Wireless">
# First time (with USB)
adb tcpip 5555
adb connect <device-ip>:5555
# Then disconnect USB, debug wirelessly</method>
<method name="Lighthouse CI">
# Run Lighthouse against deployed URL
npx lighthouse https://your-pwa.com \
--preset=perf \
--emulated-form-factor=mobile \
--output=html</method> </testing>
<best_practices>
Best Practices for Android PWAs
1. Always provide both 192x192 and 512x512 icons - required for install prompt 2. Include maskable icon - looks better on Android adaptive icons 3. Set theme_color - colors address bar and improves branding 4. Use display: standalone - most app-like experience 5. Implement custom install UI - capture beforeinstallprompt event 6. Test on Samsung Internet - significant market share 7. Don't forget Firefox - uses different engine, may have quirks </best_practices>
<overview> Images are the largest content type on web pages, accounting for over 50% of page weight on average. Proper image optimization dramatically improves LCP, reduces data usage, and speeds up mobile load times. Next.js provides powerful built-in image optimization. </overview>
<nextjs_image>
Next.js Image Component
<basic_usage>
import Image from 'next/image';
// Basic usage
<Image
src="/photo.jpg"
alt="Description"
width={800}
height={600}
/>
// Fill container (replaces layout="fill")
<div className="relative h-64">
<Image
src="/photo.jpg"
alt="Description"
fill
className="object-cover"
/>
</div></basic_usage>
<priority_prop> For LCP images (hero, above-fold):
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preloads image, disables lazy loading
/>Use priority for the largest visible image on page load. </priority_prop>
<sizes_prop> Specify responsive sizes:
<Image
src="/photo.jpg"
alt="Photo"
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Why this matters: Without sizes, browser assumes image is 100vw wide and may download larger image than needed.
Common patterns:
- Full width on mobile:
sizes="100vw" - Half width on tablet, third on desktop:
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" - Fixed width image:
sizes="400px"
</sizes_prop>
<placeholder> Blur placeholder for better UX:
// Static import (automatic blur hash)
import heroImage from '@/public/hero.jpg';
<Image
src={heroImage}
alt="Hero"
placeholder="blur"
/>
// Remote image (provide blurDataURL)
<Image
src="https://example.com/photo.jpg"
alt="Photo"
width={800}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQ..." // tiny base64 image
/></placeholder> </nextjs_image>
<configuration>
Next.js Image Configuration
// next.config.js
module.exports = {
images: {
// Enable modern formats (AVIF preferred, WebP fallback)
formats: ['image/avif', 'image/webp'],
// Remote image domains
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
},
{
protocol: 'https',
hostname: '**.cloudinary.com',
},
],
// Device breakpoints for srcset
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// Image widths for srcset (icons, thumbnails)
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Minimum cache TTL in seconds
minimumCacheTTL: 60,
},
};</configuration>
<formats>
Image Formats
| Format | Size Reduction | Browser Support | Use Case |
|---|---|---|---|
| AVIF | 50% vs JPEG | Chrome, Firefox, Safari 16+ | Best quality/size ratio |
| WebP | 25-34% vs JPEG | All modern browsers | Universal fallback |
| JPEG | Baseline | Universal | Legacy fallback |
| PNG | N/A | Universal | Transparency, graphics |
| SVG | Vector | Universal | Icons, logos, illustrations |
Next.js automatically:
- Serves AVIF to supporting browsers
- Falls back to WebP for others
- Falls back to original format for old browsers
</formats>
<responsive_images>
Responsive Images
<srcset> How srcset works:
Next.js Image generates multiple sizes and lets browser choose:
<!-- Generated by Next.js Image -->
<img
srcset="
/_next/image?url=/photo.jpg&w=640 640w,
/_next/image?url=/photo.jpg&w=750 750w,
/_next/image?url=/photo.jpg&w=828 828w,
/_next/image?url=/photo.jpg&w=1080 1080w
"
sizes="(max-width: 768px) 100vw, 50vw"
src="/_next/image?url=/photo.jpg&w=1080"
/>Browser picks appropriate size based on:
- Viewport width
- Device pixel ratio
sizesattribute
</srcset>
<art_direction> Art direction (different crops per breakpoint):
Use <picture> with multiple sources:
<picture>
<source
media="(max-width: 768px)"
srcSet="/hero-mobile.jpg"
/>
<source
media="(min-width: 769px)"
srcSet="/hero-desktop.jpg"
/>
<img src="/hero-desktop.jpg" alt="Hero" />
</picture>Or with Next.js Image (more complex):
const HeroImage = () => {
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<Image
src={isMobile ? '/hero-mobile.jpg' : '/hero-desktop.jpg'}
alt="Hero"
fill
priority
/>
);
};</art_direction> </responsive_images>
<lazy_loading>
Lazy Loading
Default behavior: Next.js Image lazy loads by default (except with priority).
// Lazy loaded (default)
<Image src="/below-fold.jpg" ... />
// Eager loaded (above fold)
<Image src="/hero.jpg" priority ... />
// Explicit loading attribute
<Image src="/photo.jpg" loading="lazy" ... />
<Image src="/critical.jpg" loading="eager" ... />Native lazy loading:
<img src="/photo.jpg" loading="lazy" />Supported in all modern browsers. </lazy_loading>
<optimization_techniques>
Optimization Techniques
<technique name="Right-size images"> Don't serve 4000px images to 400px containers.
// Specify actual display dimensions
<Image
src="/photo.jpg"
width={400}
height={300}
sizes="400px"
/></technique>
<technique name="Use appropriate quality">
// Default quality is 75 (good balance)
<Image src="/photo.jpg" quality={75} ... />
// Lower for thumbnails
<Image src="/thumbnail.jpg" quality={60} ... />
// Higher for hero images
<Image src="/hero.jpg" quality={85} priority ... /></technique>
<technique name="Preload critical images">
// In metadata
export const metadata = {
other: {
link: [
{ rel: 'preload', as: 'image', href: '/hero.webp' }
]
}
};</technique>
<technique name="Use SVG for icons/logos">
// SVG scales infinitely, tiny file size
<Image
src="/logo.svg"
width={200}
height={50}
alt="Logo"
/></technique>
<technique name="Content-aware compression"> For important images, use tools like:
- Squoosh (https://squoosh.app)
- ImageOptim (Mac)
- TinyPNG
</technique> </optimization_techniques>
<background_images>
Background Images
Option 1: CSS background (not optimized):
.hero {
background-image: url('/hero.jpg');
background-size: cover;
}Option 2: Next.js Image with fill (optimized):
<div className="relative h-screen">
<Image
src="/hero.jpg"
alt=""
fill
className="object-cover -z-10"
priority
/>
<div className="relative z-10">
{/* Content on top */}
</div>
</div></background_images>
<external_images>
External/Remote Images
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: '*.cloudinary.com',
},
],
},
};// Usage
<Image
src="https://images.unsplash.com/photo-123"
alt="Unsplash photo"
width={800}
height={600}
/></external_images>
<common_issues>
Common Issues
<issue name="CLS from images"> Problem: Images cause layout shift when loading.
Fix: Always specify width and height, or use fill with sized container.
<div className="relative aspect-video">
<Image src="/video-thumb.jpg" fill alt="Video" />
</div></issue>
<issue name="LCP image loads slowly"> Problem: Hero image is LCP but loads late.
Fix: 1. Add priority prop 2. Ensure image is in deviceSizes or imageSizes 3. Check for render-blocking resources before image </issue>
<issue name="Images too large on mobile"> Problem: Full-size image downloading on small screens.
Fix: Add proper sizes attribute:
<Image
src="/photo.jpg"
sizes="(max-width: 768px) 100vw, 50vw"
...
/></issue>
<issue name="Blur placeholder not showing"> Problem: No blur while image loads.
Fix: For static imports, use placeholder="blur". For remote images, generate and provide blurDataURL. </issue> </common_issues>
<overview> iOS Safari has the most quirks and limitations of any modern browser for mobile web development. This is because Apple restricts all browsers on iOS to use WebKit (even Chrome on iOS uses WebKit), and Safari's PWA support lags behind Chrome on Android. Understanding these quirks is essential for building mobile web apps that work well on iPhones and iPads. </overview>
<platform_versions>
iOS Version Support (2024-2025)
| iOS Version | Safari Version | Key Features/Changes |
|---|---|---|
| iOS 18 | Safari 18 | Latest, improved PWA support |
| iOS 17.4+ | Safari 17.4 | EU browser choice, other browsers can install PWAs |
| iOS 16.4+ | Safari 16.4 | Web Push notifications, Add to Home Screen from other browsers |
| iOS 16+ | Safari 16 | overscroll-behavior support |
| iOS 15 | Safari 15 | dvh/svh/lvh viewport units |
| iOS 14 | Safari 14 | Legacy, still in use |
Target minimum: iOS 15 for modern features, iOS 14 for maximum compatibility. </platform_versions>
<pwa_limitations>
PWA Limitations on iOS
<limitation name="Installation"> No automatic install prompt. Users must manually "Add to Home Screen" via Safari's Share menu. Other browsers gained this ability in iOS 16.4+.
Workaround: Add visible instructions or an install banner explaining how to add to home screen. </limitation>
<limitation name="Storage"> 50MB cache limit for PWAs. IndexedDB and Cache API combined.
7-day storage cap for web apps NOT installed to home screen. If user doesn't visit within 7 days, all data may be cleared.
No shared storage between Safari and standalone PWA. Data stored in Safari won't be accessible when app is launched from home screen.
Workaround: Encourage users to install the PWA. Use server-side storage for important data. </limitation>
<limitation name="Push Notifications"> Only works when:
- PWA is installed to home screen
- User grants permission
- iOS 16.4 or later
No background sync - notifications only arrive when app is in foreground or receives push.
Workaround: Be explicit that push notifications require home screen installation. </limitation>
<limitation name="Background Processing"> No background sync, no periodic background fetch. PWA cannot run code when not in foreground.
Workaround: Sync data on app open, use server-side processing. </limitation>
<limitation name="Device APIs"> Limited access to:
- No Bluetooth Web API
- No USB Web API
- No NFC
- Limited geolocation (works but with prompts)
- No widgets
- No direct printing
Workaround: For these features, consider native app or use fallback experiences. </limitation> </pwa_limitations>
<css_bugs>
CSS Bugs and Workarounds
<bug name="100vh Overflow"> Problem: 100vh includes the Safari address bar, causing content to overflow.
Solution:
/* Use svh for static full-height elements */
.hero {
height: 100vh; /* fallback */
height: 100svh;
}
/* Use dvh only for dynamic elements like modals */
.modal {
height: 100dvh;
}</bug>
<bug name="iOS 26 100dvh Gap"> Problem: After iOS 26, overlays using 100dvh show a gap at the bottom.
Solution:
/* Use 100vh for overlays on iOS 26+ */
.overlay {
height: 100vh;
/* Or use svh with fallback */
height: 100svh;
}</bug>
<bug name="Fixed Position with Keyboard"> Problem: When virtual keyboard opens, fixed elements may jump or be positioned incorrectly.
Solution:
/* Use visualViewport API */
.fixed-bottom {
position: fixed;
bottom: 0;
}// JavaScript adjustment
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty(
'--keyboard-inset',
`${window.innerHeight - window.visualViewport.height}px`
);
});
}</bug>
<bug name="Flexbox Inconsistencies"> Problem: Safari interprets flex shorthand differently.
Solution:
/* Be explicit, avoid shorthand */
.flex-item {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
min-width: 0; /* Prevent overflow */
}</bug>
<bug name="Details/Summary Emoji (iOS 26.1)"> Problem: Details disclosure triangle renders as emoji ▶️ instead of ▸.
Solution:
details > summary {
list-style-type: "▸ ";
}
details[open] > summary {
list-style-type: "▾ ";
}</bug>
<bug name="CSS Zoom Property"> Problem: zoom CSS property doesn't work correctly on iOS.
Solution: Use transform: scale() instead, but note it doesn't affect layout size. </bug> </css_bugs>
<scroll_behavior>
Scroll Behavior
<issue name="Rubber Band Bounce"> Problem: Elastic overscroll at page boundaries.
Solution (Safari 16+):
html {
overscroll-behavior: none;
}Solution (older iOS): Use a wrapper div:
.page-wrapper {
height: 100svh;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}</issue>
<issue name="Scroll Lock for Modals"> Problem: Background scrolls when modal is open.
Solution:
// Opening modal
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
// Closing modal
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);</issue>
<issue name="Momentum Scrolling"> Solution: Always include for smooth scrolling:
.scroll-container {
-webkit-overflow-scrolling: touch;
}</issue> </scroll_behavior>
<status_bar>
Status Bar Styling
<!-- Enable standalone mode -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar style options -->
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<!-- default: white background, black text -->
<!-- black: black background (looks all black) -->
<!-- black-translucent: transparent, content extends under (DEPRECATED but works) -->Note: black-translucent is deprecated. For content extending under status bar, use viewport-fit=cover and safe area insets instead. </status_bar>
<debugging>
Debugging iOS Safari
<method name="Safari Web Inspector"> Requirements:
- Mac with Safari
- iPhone/iPad with Safari
- USB cable
Setup: 1. iPhone: Settings → Safari → Advanced → Web Inspector → ON 2. Connect iPhone to Mac 3. Open Safari on iPhone, navigate to your page 4. Mac Safari: Develop menu → [Your iPhone] → [Your page]
Features:
- Full DOM inspection
- Console access
- Network monitoring
- Performance profiling
</method>
<method name="Eruda (No Mac)"> If you don't have a Mac, inject Eruda into your page:
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>Provides in-page DevTools console and element inspector. </method>
<method name="Remote Debugging via ngrok">
npx ngrok http 3000Access the provided URL on any device for testing. </method> </debugging>
<eu_dma_2024>
EU Digital Markets Act (2024)
In early 2024, Apple briefly removed PWA support in the EU, then reversed this decision.
Current status: PWAs work in the EU. iOS 17.4+ allows other browsers (Chrome, Firefox, Edge) to use their own engines and install PWAs.
Impact: EU users may have better PWA support through non-Safari browsers. </eu_dma_2024>
<overview> Mobile web performance directly impacts user experience and SEO. Google's Core Web Vitals measure real-world user experience through LCP, INP, and CLS. Mobile devices have slower CPUs, less memory, and often slower networks than desktop, making optimization critical. </overview>
<core_web_vitals>
Core Web Vitals (2024-2025)
<metric name="LCP - Largest Contentful Paint"> What: Time until the largest visible content element renders.
Target: < 2.5 seconds
Common LCP elements:
- Hero images (73% of mobile pages)
- Hero text/headings
- Video poster images
How to improve:
- Preload LCP image with
priorityprop (Next.js) - Use appropriate image format (WebP/AVIF)
- Optimize server response time
- Remove render-blocking resources
- Use CDN for static assets
</metric>
<metric name="INP - Interaction to Next Paint"> What: Measures responsiveness - time from user interaction to visual feedback.
Target: < 200 milliseconds
Note: INP replaced FID (First Input Delay) in March 2024.
INP components: 1. Input delay (event queue wait time) 2. Processing delay (event handler execution) 3. Presentation delay (rendering/painting)
How to improve:
- Break up long tasks (> 50ms)
- Use
requestIdleCallbackfor non-critical work - Defer/lazy-load non-essential JavaScript
- Use React transitions for heavy updates
- Avoid layout thrashing
</metric>
<metric name="CLS - Cumulative Layout Shift"> What: Measures visual stability - unexpected layout shifts.
Target: < 0.1
Common causes:
- Images without dimensions
- Ads/embeds without reserved space
- Web fonts causing FOUT/FOIT
- Dynamic content insertion
How to improve:
- Always specify width/height on images
- Reserve space for dynamic content
- Use
font-display: swapfor fonts - Avoid inserting content above existing content
- Use CSS transforms instead of layout properties
</metric> </core_web_vitals>
<measuring>
Measuring Performance
<tool name="Lighthouse">
DevTools → Lighthouse → Mobile → Generate ReportSettings for realistic mobile:
- Device: Mobile
- Categories: Performance
- Throttling: Simulated throttling (default)
Note: Lighthouse simulates mobile, but real device testing is more accurate. </tool>
<tool name="Chrome User Experience Report (CrUX)"> Real-world data from Chrome users. Access via:
- PageSpeed Insights (includes CrUX data)
- Search Console → Core Web Vitals report
- BigQuery for raw data
</tool>
<tool name="web-vitals library">
// app/layout.tsx or client component
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// Log or send to analytics
console.log(metric.name, metric.value);
// Send to analytics
if (metric.name === 'LCP' || metric.name === 'INP' || metric.name === 'CLS') {
sendToAnalytics(metric);
}
});
return null;
}</tool>
<tool name="DevTools Performance Panel"> Record real interactions: 1. DevTools → Performance 2. Click Record 3. Interact with page 4. Stop recording 5. Analyze flame chart for long tasks </tool> </measuring>
<lcp_optimization>
LCP Optimization
<technique name="Next.js Image Priority">
import Image from 'next/image';
// For hero/above-fold images
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Disables lazy loading, adds preload
/></technique>
<technique name="Preload Critical Resources">
// In layout.tsx metadata
export const metadata = {
other: {
link: [
{ rel: 'preload', href: '/critical-image.webp', as: 'image' },
{ rel: 'preload', href: '/fonts/custom.woff2', as: 'font', crossOrigin: 'anonymous' },
],
},
};</technique>
<technique name="Optimize Images">
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};</technique>
<technique name="Reduce Server Response Time">
- Use edge functions / CDN
- Cache database queries
- Optimize API endpoints
- Use streaming SSR when appropriate
</technique> </lcp_optimization>
<inp_optimization>
INP Optimization
<technique name="Break Up Long Tasks">
// Bad: Long synchronous task
function processData(items) {
items.forEach(item => heavyOperation(item));
}
// Good: Yield to main thread
async function processData(items) {
for (const item of items) {
heavyOperation(item);
// Yield to allow other work
await new Promise(resolve => setTimeout(resolve, 0));
}
}</technique>
<technique name="Use React Transitions">
import { useTransition } from 'react';
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleSearch(query: string) {
// Non-urgent update
startTransition(() => {
setResults(expensiveSearch(query));
});
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<Results data={results} />
</>
);
}</technique>
<technique name="Lazy Load Components">
import dynamic from 'next/dynamic';
// Load heavy component only when needed
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false,
});</technique>
<technique name="Debounce/Throttle Event Handlers">
import { useDebouncedCallback } from 'use-debounce';
function SearchInput() {
const debouncedSearch = useDebouncedCallback((value) => {
// Heavy search operation
performSearch(value);
}, 300);
return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}</technique> </inp_optimization>
<cls_optimization>
CLS Optimization
<technique name="Always Specify Image Dimensions">
// Always include width and height
<Image
src="/photo.jpg"
width={400}
height={300}
alt="Photo"
/></technique>
<technique name="Reserve Space for Dynamic Content">
// Skeleton with same dimensions as final content
{isLoading ? (
<div className="h-48 w-full animate-pulse bg-muted rounded" />
) : (
<ContentCard />
)}</technique>
<technique name="Use CSS Transforms for Animations">
/* Bad - causes layout shift */
.animate {
animation: slide 0.3s;
}
@keyframes slide {
from { margin-left: -100px; }
to { margin-left: 0; }
}
/* Good - GPU accelerated, no layout shift */
.animate {
animation: slide 0.3s;
}
@keyframes slide {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}</technique>
<technique name="Handle Fonts Properly">
// Next.js font optimization
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevents FOIT
preload: true,
});</technique>
<technique name="Reserve Space for Ads/Embeds">
<div className="min-h-[250px]"> {/* Standard ad height */}
<AdComponent />
</div></technique> </cls_optimization>
<mobile_specific>
Mobile-Specific Optimizations
<optimization name="Reduce JavaScript"> Mobile CPUs are 4-5x slower than desktop. Minimize and defer JavaScript.
// Defer non-critical scripts
<Script src="/analytics.js" strategy="lazyOnload" />
<Script src="/chat-widget.js" strategy="lazyOnload" /></optimization>
<optimization name="Use Efficient Image Formats">
- WebP: 25-34% smaller than JPEG
- AVIF: 50% smaller than JPEG (but slower to encode)
Next.js Image component handles this automatically. </optimization>
<optimization name="Minimize Network Requests">
- Bundle CSS/JS appropriately
- Use HTTP/2 or HTTP/3
- Enable compression (gzip/brotli)
- Cache aggressively
</optimization>
<optimization name="Test on Real Devices"> Chrome DevTools throttling doesn't match real mobile performance.
Test on:
- Mid-range Android phone
- iPhone SE (not just latest iPhone)
- Slow 3G network conditions
</optimization> </mobile_specific>
<bundle_analysis>
Bundle Analysis
# Add analyzer
pnpm add -D @next/bundle-analyzer
# Update next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer(nextConfig);
# Run analysis
ANALYZE=true pnpm buildLook for:
- Large dependencies that could be replaced
- Duplicate packages
- Unused exports
- Heavy polyfills
</bundle_analysis>
<overview> Web Push notifications allow PWAs to send notifications to users even when the app isn't open (on supported platforms). This requires a service worker, VAPID keys for authentication, and proper permission handling. iOS support is limited compared to Android. </overview>
<platform_support>
Platform Support (2024-2025)
| Platform | Support | Requirements |
|---|---|---|
| Chrome (Android) | Full | Service worker, HTTPS |
| Chrome (Desktop) | Full | Service worker, HTTPS |
| Safari (macOS) | Full | Service worker, HTTPS |
| Safari (iOS 16.4+) | Limited | Must be installed to home screen |
| Firefox | Full | Service worker, HTTPS |
| Samsung Internet | Full | Service worker, HTTPS |
iOS Limitations:
- Only works when PWA is installed to home screen
- No badge API
- No background push when app is completely closed
- Requires explicit permission after install
</platform_support>
<vapid_keys>
VAPID Keys
VAPID (Voluntary Application Server Identification) authenticates your server to push services.
<generating> Generate keys:
# Using web-push package
npm install -g web-push
web-push generate-vapid-keysOutput:
Public Key: BNbxg...
Private Key: dGVzd...Store securely:
# .env.local
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BNbxg...
VAPID_PRIVATE_KEY=dGVzd...
VAPID_SUBJECT=mailto:your@email.com</generating> </vapid_keys>
<implementation>
Implementation
<step name="1. Request Permission">
// components/PushNotificationManager.tsx
'use client';
import { useState, useEffect } from 'react';
export function PushNotificationManager() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
}
}, []);
const requestPermission = async () => {
if (!('Notification' in window)) {
alert('Notifications not supported');
return;
}
const result = await Notification.requestPermission();
setPermission(result);
if (result === 'granted') {
await subscribeToPush();
}
};
const subscribeToPush = async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
});
setSubscription(subscription);
// Send to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
} catch (error) {
console.error('Push subscription failed:', error);
}
};
return (
<div>
{permission === 'default' && (
<button onClick={requestPermission}>
Enable Notifications
</button>
)}
{permission === 'granted' && <p>Notifications enabled</p>}
{permission === 'denied' && <p>Notifications blocked</p>}
</div>
);
}
// Helper function
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)));
}</step>
<step name="2. Handle Push in Service Worker">
// public/sw.js
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
actions: data.actions || [],
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data.url;
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
// Focus existing window if open
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});</step>
<step name="3. Server-Side Push (API Route)">
// app/api/push/send/route.ts
import webpush from 'web-push';
import { NextResponse } from 'next/server';
webpush.setVapidDetails(
process.env.VAPID_SUBJECT!,
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function POST(request: Request) {
const { subscription, title, body, url } = await request.json();
try {
await webpush.sendNotification(
subscription,
JSON.stringify({ title, body, url })
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Push failed:', error);
return NextResponse.json({ error: 'Push failed' }, { status: 500 });
}
}</step>
<step name="4. Store Subscriptions">
// app/api/push/subscribe/route.ts
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const subscription = await request.json();
// Store in your database
// await db.pushSubscriptions.create({ data: subscription });
return NextResponse.json({ success: true });
}</step> </implementation>
<ios_specific>
iOS-Specific Considerations
<requirement name="Home Screen Installation"> Push notifications ONLY work on iOS when: 1. User visits site in Safari 2. User taps Share → "Add to Home Screen" 3. User opens PWA from home screen 4. User grants notification permission
UX Recommendation: Guide users through installation before asking for push permission. </requirement>
<requirement name="Permission Flow">
// Check if installed as PWA on iOS
const isIOSPWA = () => {
return (
'standalone' in navigator &&
(navigator as any).standalone === true
);
};
// Show appropriate UI
{!isIOSPWA() && isIOS() && (
<div className="install-prompt">
<p>Install this app to enable notifications</p>
<p>Tap Share → Add to Home Screen</p>
</div>
)}
{isIOSPWA() && permission === 'default' && (
<button onClick={requestPermission}>
Enable Notifications
</button>
)}</requirement>
<limitation name="No Background Delivery"> On iOS, push notifications may not be delivered if the PWA is completely closed. They work when:
- App is in foreground
- App is in background (recently used)
- Device receives push while app is "warm"
Workaround: Send push notifications for time-sensitive content. Don't rely on them for critical alerts on iOS. </limitation> </ios_specific>
<best_practices>
Best Practices
1. Ask at the right time - Don't request permission on page load. Wait for user action or context that makes notifications relevant.
2. Explain value first - Tell users why they should enable notifications before asking.
3. Handle denial gracefully - If permission denied, show alternative (email notifications, in-app messages).
4. Test on real devices - Push behavior differs significantly between platforms.
5. Keep payloads small - Push payload limit varies (4KB typical). Send minimal data, fetch details on click.
6. Include action buttons - Make notifications actionable when appropriate.
7. Respect quiet hours - Consider time zones and don't send notifications at inappropriate times.
8. Provide unsubscribe option - Let users easily disable notifications in your app. </best_practices>
<debugging>
Debugging Push Notifications
Chrome DevTools: 1. Application tab → Service Workers 2. Click "Push" to simulate a push message 3. Check Console for errors
Test payload:
{
"title": "Test Notification",
"body": "This is a test message",
"url": "/notifications"
}Check subscription:
navigator.serviceWorker.ready.then(reg => {
reg.pushManager.getSubscription().then(sub => {
console.log('Subscription:', JSON.stringify(sub));
});
});</debugging>
<overview> The Web App Manifest (manifest.json) is a JSON file that provides metadata about your PWA. It controls how the app appears when installed, including name, icons, splash screen, and display mode. Proper manifest configuration is required for PWA installability. </overview>
<nextjs_manifest>
Next.js Manifest Configuration
<method name="TypeScript Manifest (Recommended)">
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Progressive Web App',
short_name: 'MyPWA',
description: 'A description of your app',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}</method>
<method name="JSON Manifest">
// public/manifest.json
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A description of your app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}Link in HTML:
<link rel="manifest" href="/manifest.json"></method> </nextjs_manifest>
<required_fields>
Required Fields for Installability
| Field | Requirement | Notes |
|---|---|---|
name or short_name | Required | At least one must be present |
start_url | Required | Entry point when app opens |
display | Required | Must be standalone, fullscreen, or minimal-ui |
icons | Required | At least 192x192 and 512x512 |
Chrome-specific: Also requires a registered service worker with fetch handler. </required_fields>
<fields_reference>
All Manifest Fields
<field name="name"> Full name of the application. Used in app install dialog and home screen if space allows.
"name": "My Progressive Web Application"</field>
<field name="short_name"> Short name for limited space. Keep under 12 characters to avoid truncation.
"short_name": "MyPWA"</field>
<field name="description"> Description shown in app stores and install dialogs.
"description": "A fast, reliable app for managing your tasks"</field>
<field name="start_url"> URL that opens when app launches. Should be your main app screen, not a marketing page.
"start_url": "/"Can include query params for analytics:
"start_url": "/?source=pwa"</field>
<field name="display"> How the app displays when launched:
standalone- Most app-like, no browser UI (recommended)fullscreen- No browser UI, no status bar (games)minimal-ui- Some browser controls visiblebrowser- Regular browser tab
"display": "standalone"</field>
<field name="background_color"> Background color for splash screen. Should match your app's background.
"background_color": "#ffffff"</field>
<field name="theme_color"> Colors browser address bar (Android) and status bar area.
"theme_color": "#4285f4"Can also set via meta tag for dynamic updates:
<meta name="theme-color" content="#4285f4"></field>
<field name="orientation"> Preferred orientation:
portrait-primary- Portrait, home button at bottomlandscape-primary- Landscapeany- Follow device orientation
"orientation": "portrait-primary"</field>
<field name="scope"> Navigation scope. URLs outside scope open in browser.
"scope": "/app/"Default is the directory containing the manifest. </field>
<field name="lang"> Primary language.
"lang": "en-US"</field>
<field name="dir"> Text direction: ltr, rtl, or auto.
"dir": "ltr"</field> </fields_reference>
<icons_configuration>
Icons Configuration
<requirement name="Minimum Required">
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]</requirement>
<recommendation name="Full Icon Set">
"icons": [
{
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]</recommendation>
<concept name="Maskable Icons"> Maskable icons are designed for Android adaptive icons. They have a "safe zone" in the center and can be cropped to circles, squares, or rounded rectangles.
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}Design guidelines:
- Important content in center 80% (safe zone)
- Full bleed background
- Test with maskable icon tool: https://maskable.app/
</concept>
<concept name="SVG Icons"> For crisp icons at any size:
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}Always include PNG fallback for older browsers. </concept> </icons_configuration>
<ios_specific>
iOS-Specific Configuration
iOS requires additional HTML meta tags and link elements:
<!-- Enable standalone mode -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar style -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- App title (separate from manifest) -->
<meta name="apple-mobile-web-app-title" content="MyPWA">
<!-- Apple Touch Icon -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- Multiple sizes for different devices -->
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/apple-touch-icon-120.png">Next.js metadata API:
export const metadata: Metadata = {
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'MyPWA',
},
}</ios_specific>
<splash_screens>
iOS Splash Screens
iOS doesn't auto-generate splash screens from manifest. You need specific images:
<!-- iPhone 15 Pro Max, 14 Pro Max -->
<link rel="apple-touch-startup-image"
href="/splash/apple-splash-1290-2796.png"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)">
<!-- iPhone 15 Pro, 14 Pro -->
<link rel="apple-touch-startup-image"
href="/splash/apple-splash-1179-2556.png"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)">
<!-- ... many more for each device/orientation -->Generate automatically:
npx pwa-asset-generator ./logo.png ./public/splash \
--splash-only \
--type png</splash_screens>
<validation>
Validating Your Manifest
Chrome DevTools: 1. Open DevTools → Application tab 2. Click "Manifest" in sidebar 3. Check for errors and warnings
Lighthouse: 1. DevTools → Lighthouse 2. Check "Progressive Web App" 3. Run audit
Common errors:
- Missing icons (need 192 and 512)
- Invalid
displayvalue start_urlnot withinscope- Icons return 404
- Not served over HTTPS
</validation>
<generation_tools>
Icon Generation Tools
PWA Asset Generator (Recommended):
npx pwa-asset-generator ./logo.png ./public/iconsGenerates all icon sizes and HTML tags.
Maskable.app: https://maskable.app/editor Preview and create maskable icons.
RealFaviconGenerator: https://realfavicongenerator.net/ Web-based, generates full favicon set. </generation_tools>
<overview> Responsive design patterns ensure web apps work well across all screen sizes. This reference covers common mistakes, anti-patterns, and best practices for mobile-first responsive design with React and Tailwind CSS. </overview>
<mobile_first>
Mobile-First Approach
<principle> Write mobile styles first, then add breakpoints for larger screens:
/* Mobile first (default) */
.container {
padding: 1rem;
display: flex;
flex-direction: column;
}
/* Tablet and up */
@media (min-width: 768px) {
.container {
padding: 2rem;
flex-direction: row;
}
}
/* Desktop */
@media (min-width: 1024px) {
.container {
max-width: 1200px;
margin: 0 auto;
}
}</principle>
<tailwind_approach> Tailwind CSS mobile-first:
<div className="
p-4 flex flex-col {/* Mobile */}
md:p-8 md:flex-row {/* Tablet */}
lg:max-w-6xl lg:mx-auto {/* Desktop */}
">Tailwind breakpoints:
sm: 640pxmd: 768pxlg: 1024pxxl: 1280px2xl: 1536px
</tailwind_approach> </mobile_first>
<common_mistakes>
Common Mistakes
<mistake name="Device-Specific Breakpoints"> Bad: Targeting specific devices:
/* Don't do this */
@media (width: 375px) { } /* iPhone SE */
@media (width: 390px) { } /* iPhone 14 */Good: Use content-based breakpoints:
/* Content-driven breakpoints */
@media (min-width: 768px) { }Device widths change constantly. Design for content, not devices. </mistake>
<mistake name="Fixed Widths on Mobile"> Bad:
.card {
width: 400px; /* Will overflow on small screens */
}Good:
.card {
width: 100%;
max-width: 400px;
}</mistake>
<mistake name="Horizontal Overflow"> Bad: Content overflows horizontally, causing scroll.
Fix: Check for:
- Fixed-width elements
- Images without max-width
- Tables without horizontal scroll wrapper
- Long unbreakable strings
/* Prevent horizontal overflow */
body {
overflow-x: hidden;
}
img {
max-width: 100%;
height: auto;
}
/* Handle long words */
.text {
word-wrap: break-word;
overflow-wrap: break-word;
}</mistake>
<mistake name="Small Touch Targets"> Bad:
.icon-button {
width: 24px;
height: 24px;
}Good:
.icon-button {
width: 44px; /* Minimum touch target */
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}</mistake>
<mistake name="Small Font Sizes"> Bad:
.body-text {
font-size: 12px;
}Good:
.body-text {
font-size: 16px; /* Minimum for comfortable reading */
}iOS Safari zooms inputs with font-size < 16px. </mistake>
<mistake name="Using 100vh"> See viewport-layout.md for the full explanation.
Bad:
.hero { height: 100vh; }Good:
.hero {
height: 100vh;
height: 100svh;
}</mistake>
<mistake name="Ignoring Landscape Orientation"> Remember: Users rotate phones. Test:
- Navigation in landscape
- Forms with keyboard in landscape
- Media player layouts
- Safe areas shift in landscape (notch on side)
</mistake>
<mistake name="Desktop-Only Hover States"> Bad: Relying on hover for critical functionality:
.dropdown-menu {
display: none;
}
.dropdown:hover .dropdown-menu {
display: block;
}Good: Make it work with tap/click:
const [isOpen, setIsOpen] = useState(false);
<button onClick={() => setIsOpen(!isOpen)}>Menu</button>
{isOpen && <DropdownMenu />}</mistake> </common_mistakes>
<patterns>
Responsive Patterns
<pattern name="Fluid Typography"> Scale font size based on viewport:
/* clamp(min, preferred, max) */
.heading {
font-size: clamp(1.5rem, 4vw, 3rem);
}
.body {
font-size: clamp(1rem, 2.5vw, 1.25rem);
}Tailwind approach:
<h1 className="text-2xl md:text-4xl lg:text-5xl">
Responsive Heading
</h1></pattern>
<pattern name="Responsive Grid">
<div className="
grid
grid-cols-1
sm:grid-cols-2
lg:grid-cols-3
xl:grid-cols-4
gap-4
">
{items.map(item => <Card key={item.id} {...item} />)}
</div></pattern>
<pattern name="Stack to Inline">
<div className="
flex flex-col space-y-4
md:flex-row md:space-y-0 md:space-x-4
">
<Input />
<Button>Submit</Button>
</div></pattern>
<pattern name="Show/Hide Elements">
{/* Show on mobile only */}
<nav className="md:hidden">
<MobileNav />
</nav>
{/* Show on desktop only */}
<nav className="hidden md:block">
<DesktopNav />
</nav></pattern>
<pattern name="Responsive Images">
<Image
src="/photo.jpg"
alt="Photo"
width={1200}
height={800}
className="w-full h-auto"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/></pattern>
<pattern name="Container Queries"> Modern CSS for component-based responsive design:
.card-container {
container-type: inline-size;
}
.card {
padding: 1rem;
}
@container (min-width: 400px) {
.card {
padding: 2rem;
display: flex;
}
}Tailwind support via @tailwindcss/container-queries plugin. </pattern> </patterns>
<navigation>
Mobile Navigation Patterns
<pattern name="Bottom Navigation"> Best for apps with 3-5 main sections:
<nav className="
fixed bottom-0 left-0 right-0
flex justify-around
bg-background border-t
pb-safe {/* Safe area for home indicator */}
md:hidden {/* Hide on desktop */}
">
<NavItem icon={Home} label="Home" />
<NavItem icon={Search} label="Search" />
<NavItem icon={User} label="Profile" />
</nav></pattern>
<pattern name="Hamburger Menu"> For complex navigation:
const [isOpen, setIsOpen] = useState(false);
<button
className="md:hidden p-2"
onClick={() => setIsOpen(!isOpen)}
aria-label="Toggle menu"
>
<Menu className="w-6 h-6" />
</button>
{isOpen && (
<div className="
fixed inset-0 z-50
bg-background
md:hidden
">
<MobileMenu onClose={() => setIsOpen(false)} />
</div>
)}</pattern>
<pattern name="Sliding Drawer">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<nav>...</nav>
</SheetContent>
</Sheet></pattern> </navigation>
<forms>
Responsive Forms
<pattern name="Stacked to Inline">
<form className="space-y-4">
<div className="flex flex-col md:flex-row md:gap-4">
<div className="flex-1">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" />
</div>
<div className="flex-1">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" />
</div>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" className="text-base" /> {/* 16px prevents iOS zoom */}
</div>
</form></pattern>
<pattern name="Full-Width Buttons on Mobile">
<Button className="w-full md:w-auto">
Submit
</Button></pattern> </forms>
<testing>
Testing Responsive Design
1. Use real devices - Emulators miss nuances 2. Test orientations - Portrait and landscape 3. Test with keyboard - Layout changes when keyboard opens 4. Check content reflow - Text should reflow, not truncate unexpectedly 5. Verify touch targets - 44px minimum 6. Test safe areas - Notches and home indicators 7. Check font rendering - Fonts render differently per OS </testing>
<overview> Service workers are JavaScript files that run in the background, separate from your web page. They enable offline functionality, push notifications, and background sync. For PWAs, service workers are required for installability and provide the foundation for offline-first experiences. </overview>
<fundamentals>
Service Worker Lifecycle
Download → Install → Waiting → Activate → Running1. Download: Browser downloads the service worker file 2. Install: install event fires, cache initial assets 3. Waiting: New SW waits for old SW to release control 4. Activate: activate event fires, clean up old caches 5. Running: SW intercepts fetch requests </fundamentals>
<nextjs_options>
Next.js Service Worker Options
<option name="Native Service Worker (Recommended 2025)"> Next.js 14+ supports PWAs without additional packages.
Create public/sw.js:
const CACHE_NAME = 'my-app-v1';
const OFFLINE_URL = '/offline';
const PRECACHE_ASSETS = [
'/',
'/offline',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// Install: cache essential assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_ASSETS);
})
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch: network-first for pages, cache-first for assets
self.addEventListener('fetch', (event) => {
const { request } = event;
// Skip non-GET requests
if (request.method !== 'GET') return;
// Network-first for HTML pages
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
// Cache successful responses
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, clone);
});
return response;
})
.catch(() => caches.match(OFFLINE_URL))
);
return;
}
// Cache-first for assets
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
// Cache new assets
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, clone);
});
return response;
});
})
);
});</option>
<option name="Serwist (next-pwa successor)"> The modern replacement for next-pwa:
pnpm add @serwist/next @serwist/precaching @serwist/strategies// next.config.js
const withSerwist = require("@serwist/next").default({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
disable: process.env.NODE_ENV === "development",
});
module.exports = withSerwist({
// Your Next.js config
});// app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import { Serwist } from "serwist";
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
});
serwist.addEventListeners();</option>
<option name="next-pwa (Legacy - Not Recommended)"> Note: next-pwa is unmaintained since July 2024. Use Serwist instead.
If you must use it:
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
});</option> </nextjs_options>
<registration>
Service Worker Registration
// components/ServiceWorkerRegistration.tsx
'use client';
import { useEffect } from 'react';
export function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Every hour
})
.catch((error) => {
console.error('SW registration failed:', error);
});
}
}, []);
return null;
}Add to layout:
// app/layout.tsx
import { ServiceWorkerRegistration } from '@/components/ServiceWorkerRegistration';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<ServiceWorkerRegistration />
</body>
</html>
);
}</registration>
<caching_strategies>
Caching Strategies
<strategy name="Cache First (Cache, falling back to network)"> Best for: Static assets (images, fonts, CSS, JS)
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});Pros: Fast, works offline Cons: May serve stale content </strategy>
<strategy name="Network First (Network, falling back to cache)"> Best for: HTML pages, API data that needs freshness
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache the fresh response
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request))
);
});Pros: Always fresh when online Cons: Slow on poor connections </strategy>
<strategy name="Stale While Revalidate"> Best for: Content that updates but staleness is acceptable (avatars, article content)
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});Pros: Fast response, eventually consistent Cons: May show stale data briefly </strategy>
<strategy name="Cache Only"> Best for: Pre-cached app shell assets
event.respondWith(caches.match(event.request));</strategy>
<strategy name="Network Only"> Best for: Real-time data, analytics, non-cacheable requests
event.respondWith(fetch(event.request));</strategy> </caching_strategies>
<strategy_by_resource>
Recommended Strategy by Resource Type
| Resource Type | Strategy | Rationale |
|---|---|---|
| HTML pages | Network First | Need fresh content, fallback offline |
| App shell (layout) | Cache First | Rarely changes, fast loading |
| CSS/JS bundles | Cache First with version | Hashed filenames handle updates |
| Images | Cache First | Rarely change, save bandwidth |
| Fonts | Cache First | Never change |
| API (GET) | Stale While Revalidate | Balance freshness and speed |
| API (POST/PUT) | Network Only | Can't cache mutations |
| Analytics | Network Only | Must reach server |
</strategy_by_resource>
<cache_versioning>
Cache Versioning
Always version your cache to handle updates:
const CACHE_VERSION = 'v2'; // Increment on deploy
const CACHE_NAME = `my-app-${CACHE_VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith('my-app-') && name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});</cache_versioning>
<offline_page>
Offline Fallback Page
Create an offline page that's cached on install:
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-svh items-center justify-center p-4">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold mb-4">You're Offline</h1>
<p className="text-muted-foreground mb-6">
Please check your internet connection and try again.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg"
>
Try Again
</button>
</div>
</div>
);
}Pre-cache in service worker:
const PRECACHE = ['/', '/offline'];</offline_page>
<ios_limitations>
iOS Service Worker Limitations
1. 50MB storage limit - Cache + IndexedDB combined 2. No background sync - Can't sync when app is closed 3. No periodic background fetch - No scheduled updates 4. 7-day data expiry - If PWA not installed, data may be cleared 5. No push when closed - Push only works when PWA is in foreground or receives push
Workarounds:
- Encourage users to install PWA to home screen
- Keep cache size small
- Store important data server-side
- Sync data on app open
</ios_limitations>
<debugging>
Debugging Service Workers
Chrome DevTools: 1. Application tab → Service Workers 2. Check "Update on reload" for development 3. View cache contents in "Cache Storage"
Unregister for testing:
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => {
registration.unregister();
});
});Force update:
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});</debugging>
<overview> Testing mobile web apps requires a combination of automated tools, browser emulation, and real device testing. This reference covers Lighthouse audits, Chrome DevTools, Safari Web Inspector, and debugging techniques for iOS and Android. </overview>
<lighthouse>
Lighthouse Audits
<running> Chrome DevTools: 1. Open DevTools (Cmd+Opt+I / Ctrl+Shift+I) 2. Go to Lighthouse tab 3. Select:
- Mode: Navigation (default)
- Device: Mobile
- Categories: Performance, Accessibility, Best Practices, SEO, PWA
4. Click "Analyze page load" </running>
<cli> Command Line:
# Install
npm install -g lighthouse
# Run audit
lighthouse https://example.com --preset=perf --output=html --output-path=./report.html
# Mobile-specific
lighthouse https://example.com \
--emulated-form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--output=json</cli>
<key_audits> Performance:
- LCP (Largest Contentful Paint): < 2.5s
- INP (Interaction to Next Paint): < 200ms
- CLS (Cumulative Layout Shift): < 0.1
PWA (Note: PWA audits deprecated in Lighthouse):
- Service worker registered
- Manifest valid
- HTTPS
- Offline fallback
Accessibility:
- Color contrast
- Touch targets
- Alt text
- Focus management
Best Practices:
- HTTPS
- No console errors
- Image aspect ratios
</key_audits>
<limitations> Lighthouse limitations:
- Simulates mobile, doesn't use real device
- May not catch iOS-specific bugs
- Network throttling differs from real conditions
- Scores vary between runs
</limitations> </lighthouse>
<chrome_devtools>
Chrome DevTools Mobile Emulation
<device_mode> Enable device mode:
- Click device toolbar icon (or Cmd+Shift+M / Ctrl+Shift+M)
- Select device from dropdown
Available presets:
- iPhone SE, 14 Pro, 14 Pro Max
- Pixel 7, Galaxy S20
- iPad Mini, iPad Pro
- Responsive (custom dimensions)
</device_mode>
<features> Useful features:
- Network throttling: Network tab → Throttling → Slow 3G
- CPU throttling: Performance tab → Capture settings → CPU: 4x slowdown
- Touch simulation: Automatically enabled in device mode
- Geolocation: Sensors tab → Location
- Device orientation: Sensors tab → Orientation
</features>
<limitations> What DevTools can't simulate:
- iOS Safari CSS bugs
- Real touch behavior nuances
- Safe area insets (approximates)
- Actual mobile CPU performance
- iOS keyboard behavior
- WebKit-specific issues (Chrome uses Blink on desktop)
Always supplement with real device testing. </limitations> </chrome_devtools>
<ios_debugging>
iOS Safari Debugging
<safari_web_inspector> Prerequisites: 1. Mac with Safari 2. iPhone/iPad with iOS 14+ 3. USB cable
Setup: 1. iPhone: Settings → Safari → Advanced → Web Inspector: ON 2. iPhone: Settings → Safari → Advanced → Remote Automation: ON (optional) 3. Mac: Safari → Settings → Advanced → Show Develop menu in menu bar
Connect: 1. Connect iPhone to Mac via USB 2. Open Safari on iPhone, navigate to your page 3. Mac Safari: Develop menu → [Your iPhone] → [Your page URL]
Available tools:
- Console
- Elements/DOM inspector
- Network
- Resources
- Timelines (Performance)
- Storage
</safari_web_inspector>
<remote_debugging> Without Mac (using ngrok/localtunnel):
# Expose localhost to internet
npx ngrok http 3000
# Or
npx localtunnel --port 3000Open the URL on your iPhone. Use Eruda for in-page debugging:
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script></remote_debugging>
<simulator> Using iOS Simulator (Mac only):
# List available simulators
xcrun simctl list devices
# Boot a simulator
xcrun simctl boot "iPhone 15 Pro"
# Open Simulator app
open -a SimulatorSafari in Simulator appears in Safari Develop menu. </simulator> </ios_debugging>
<android_debugging>
Android Chrome Debugging
<remote_debugging> Prerequisites: 1. Android device with USB debugging enabled 2. Chrome on Android 3. Chrome on desktop
Setup: 1. Android: Settings → Developer Options → USB Debugging: ON 2. Connect device via USB 3. Desktop Chrome: navigate to chrome://inspect 4. Find your device and page, click "inspect"
Features:
- Full DevTools (Elements, Console, Network, etc.)
- Live editing
- Screencast mode
</remote_debugging>
<adb_wireless> Wireless debugging (Android 11+): 1. Enable Wireless Debugging in Developer Options 2. adb pair <ip>:<port> (use pairing code from device) 3. adb connect <ip>:<port> </adb_wireless> </android_debugging>
<testing_checklist>
Mobile Testing Checklist
<viewport_layout> Viewport & Layout:
- [ ] No horizontal overflow
- [ ] Content readable without zooming
- [ ] Safe areas handled (notch, home indicator)
- [ ] Works in portrait and landscape
- [ ] 100vh/svh used correctly
</viewport_layout>
<touch> Touch & Interaction:
- [ ] Touch targets 44px+ minimum
- [ ] No 300ms tap delay
- [ ] Scroll behavior smooth
- [ ] Forms usable with virtual keyboard
- [ ] Gestures work correctly
</touch>
<performance> Performance:
- [ ] LCP < 2.5s on 3G
- [ ] INP < 200ms
- [ ] CLS < 0.1
- [ ] Images optimized (WebP/AVIF)
- [ ] JavaScript not blocking main thread
</performance>
<pwa> PWA:
- [ ] Manifest valid (check in DevTools)
- [ ] Service worker registered
- [ ] Offline page works
- [ ] Icons loading correctly
- [ ] Installs on iOS (Add to Home Screen)
- [ ] Installs on Android (install prompt)
- [ ] Theme color applied
</pwa>
<cross_browser> Cross-Browser:
- [ ] Safari iOS
- [ ] Chrome iOS (uses WebKit)
- [ ] Chrome Android
- [ ] Samsung Internet
- [ ] Firefox Android
</cross_browser>
<accessibility> Accessibility:
- [ ] VoiceOver navigation works (iOS)
- [ ] TalkBack navigation works (Android)
- [ ] Focus order logical
- [ ] Color contrast sufficient
- [ ] Text resizable without breaking layout
</accessibility> </testing_checklist>
<common_debugging_scenarios>
Common Debugging Scenarios
<scenario name="CSS not working on iOS"> 1. Check for Safari-specific bugs (see ios-safari-quirks.md) 2. Verify viewport meta tag is correct 3. Check for -webkit- prefix requirements 4. Test in Safari Web Inspector on real device 5. Compare behavior in Chrome iOS (also WebKit) </scenario>
<scenario name="Layout breaks with keyboard"> 1. Check for 100vh elements (use svh instead) 2. Verify fixed positioning behavior 3. Use visualViewport API if needed 4. Test with keyboard in both orientations </scenario>
<scenario name="PWA not installing"> 1. Check manifest in DevTools → Application → Manifest 2. Verify HTTPS (or localhost) 3. Confirm service worker registered 4. Check icon requirements (192x192, 512x512) 5. Ensure start_url is accessible </scenario>
<scenario name="Touch feels unresponsive"> 1. Check for 300ms delay (add touch-action: manipulation) 2. Look for heavy JavaScript blocking main thread 3. Profile with Performance tab 4. Check for passive event listener issues </scenario>
<scenario name="Performance issues on mobile"> 1. Run Lighthouse on Mobile 2. Check for large images (use Next.js Image) 3. Profile JavaScript with CPU throttling 4. Check for layout thrashing 5. Test on mid-range device, not just flagship </scenario> </common_debugging_scenarios>
<tools>
Testing Tools & Services
| Tool | Purpose | Cost |
|---|---|---|
| BrowserStack | Real device cloud testing | Paid |
| Sauce Labs | Cross-browser testing | Paid |
| LambdaTest | Browser testing | Paid |
| PageSpeed Insights | Performance testing | Free |
| WebPageTest | Detailed performance | Free |
| Eruda | In-page mobile console | Free |
| ngrok | Expose localhost | Free tier |
| web-vitals | Real user metrics | Free |
</tools>
<overview> Touch interactions on mobile differ significantly from mouse interactions on desktop. This reference covers eliminating the 300ms tap delay, proper touch target sizing, gesture handling in React, and scroll behavior customization. </overview>
<tap_delay>
300ms Tap Delay
<problem> Historically, mobile browsers waited 300ms after a tap to see if the user would double-tap to zoom. This made apps feel sluggish. </problem>
<solution name="CSS touch-action (Recommended)"> Modern solution - no JavaScript required:
/* Apply to interactive elements */
a, button, input, select, textarea, label, summary, [role="button"] {
touch-action: manipulation;
}touch-action: manipulation tells the browser:
- Allow panning and pinching
- Don't wait for double-tap zoom
- Fire click events immediately
Browser support: iOS Safari 9.3+, all modern browsers. </solution>
<solution name="Viewport Meta Tag"> Browsers also remove the delay when:
<meta name="viewport" content="width=device-width">This signals the page is mobile-optimized and doesn't need double-tap zoom. </solution>
<anti_pattern name="FastClick Library"> Don't use FastClick - it's obsolete since iOS 9.3 (2016).
// DON'T DO THIS
import FastClick from 'fastclick';
FastClick.attach(document.body);FastClick can introduce bugs and is no longer needed. </anti_pattern>
<edge_case name="PWA Standalone Mode"> There's an edge case where the delay persists in standalone PWAs with:
<meta name="apple-mobile-web-app-capable" content="yes">Fix by explicitly adding touch-action:
* {
touch-action: manipulation;
}</edge_case> </tap_delay>
<touch_targets>
Touch Target Sizing
<requirements> Minimum sizes:
- Apple HIG: 44×44pt (points)
- Material Design: 48×48dp (density-independent pixels)
- WCAG 2.1 AAA: 44×44 CSS pixels
Recommendation: Use 44px minimum, 48px preferred. </requirements>
<implementation>
/* Minimum touch target */
.touch-target {
min-height: 44px;
min-width: 44px;
padding: 12px;
}
/* Icon button with proper touch area */
.icon-button {
/* Visual size */
width: 24px;
height: 24px;
/* But clickable area is larger */
padding: 10px;
margin: -10px; /* Negative margin to maintain layout */
}
/* Or use pseudo-element for larger hit area */
.small-button {
position: relative;
}
.small-button::before {
content: '';
position: absolute;
top: -10px;
right: -10px;
bottom: -10px;
left: -10px;
}</implementation>
<spacing> Spacing between touch targets:
- Minimum 8px between adjacent targets
- Prevents accidental taps on wrong element
</spacing> </touch_targets>
<gesture_libraries>
Gesture Handling in React
<library name="@use-gesture (Web)"> Best for React web apps - works with react-spring:
pnpm add @use-gesture/reactimport { useDrag, useGesture } from '@use-gesture/react';
import { useSpring, animated } from '@react-spring/web';
function DraggableCard() {
const [{ x, y }, api] = useSpring(() => ({ x: 0, y: 0 }));
const bind = useDrag(({ offset: [ox, oy] }) => {
api.start({ x: ox, y: oy });
});
return (
<animated.div
{...bind()}
style={{
x, y,
touchAction: 'none', // IMPORTANT: prevent scroll interference
}}
>
Drag me
</animated.div>
);
}Important: Always set touchAction: 'none' on draggable elements to prevent browser scroll interference. </library>
<library name="React Native Gesture Handler"> For React Native apps (not web):
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
function SwipeableItem() {
const pan = Gesture.Pan()
.onUpdate((e) => {
// Handle pan
})
.onEnd((e) => {
// Handle end
});
return (
<GestureDetector gesture={pan}>
<View>Swipe me</View>
</GestureDetector>
);
}</library> </gesture_libraries>
<touch_events>
Touch Event Handling
<lifecycle> Touch event sequence: 1. touchstart - Finger touches screen 2. touchmove - Finger moves (may fire many times) 3. touchend - Finger lifts 4. click - Fires after touchend (with delay if touch-action not set) </lifecycle>
<basic_handling>
function TouchableElement() {
const handleTouchStart = (e: React.TouchEvent) => {
// Get touch position
const touch = e.touches[0];
console.log(`Touch at ${touch.clientX}, ${touch.clientY}`);
};
const handleTouchMove = (e: React.TouchEvent) => {
// Track touch movement
const touch = e.touches[0];
// Handle movement...
};
const handleTouchEnd = (e: React.TouchEvent) => {
// Touch ended
};
return (
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
Touch me
</div>
);
}</basic_handling>
<pointer_events> Consider Pointer Events for unified mouse/touch handling:
function UnifiedElement() {
const handlePointerDown = (e: React.PointerEvent) => {
console.log(`Pointer type: ${e.pointerType}`); // 'mouse', 'touch', 'pen'
};
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
Works with mouse and touch
</div>
);
}</pointer_events> </touch_events>
<scroll_behavior>
Scroll Behavior
<momentum_scrolling> Enable smooth momentum scrolling (iOS):
.scroll-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}</momentum_scrolling>
<prevent_scroll> Prevent scroll while dragging:
const bind = useDrag(({ event }) => {
event.preventDefault(); // Prevent scroll during drag
}, {
filterTaps: true,
});Or with CSS:
.dragging {
touch-action: none;
}</prevent_scroll>
<overscroll> Control overscroll (rubber band) behavior:
/* Prevent rubber band entirely */
html {
overscroll-behavior: none;
}
/* Contain to element (prevent scroll chaining) */
.modal {
overscroll-behavior: contain;
}
/* Only on y-axis */
.list {
overscroll-behavior-y: contain;
}</overscroll>
<pull_to_refresh> Disable browser pull-to-refresh:
body {
overscroll-behavior-y: contain;
}Note: This also disables the pull-to-refresh gesture. Only use if implementing custom pull-to-refresh. </pull_to_refresh> </scroll_behavior>
<common_issues>
Common Touch Issues
<issue name="Ghost clicks"> When touch handler removes/moves element, click may fire on element now at that position.
Fix: Use e.preventDefault() in touch handler or delay removal. </issue>
<issue name="Scroll jank during touch"> Heavy JavaScript in touch handlers causes scroll jank.
Fix: Use passive event listeners:
element.addEventListener('touchmove', handler, { passive: true });React handles this automatically for scroll events. </issue>
<issue name="Touch not working on iOS"> iOS Safari has quirks with touch events on non-clickable elements.
Fix: Add cursor: pointer to make element "clickable":
.touchable {
cursor: pointer;
}</issue>
<issue name="Multi-touch tracking"> Touches can be added/removed in any order. Don't rely on array order.
Fix: Track touches by identifier:
const touches = new Map();
function handleTouchStart(e) {
for (const touch of e.changedTouches) {
touches.set(touch.identifier, {
x: touch.clientX,
y: touch.clientY,
});
}
}</issue> </common_issues>
<accessibility>
Accessibility Considerations
1. Don't disable zoom - Users with vision impairments need to zoom 2. Support keyboard - All touch interactions should have keyboard equivalents 3. Provide alternatives - Some users can't perform complex gestures 4. Use semantic elements - button is more accessible than div with touch handler 5. Test with assistive tech - VoiceOver, TalkBack handle touch differently </accessibility>
<overview> Viewport and layout issues are the most common source of mobile web bugs. This reference covers viewport units (vh, svh, dvh, lvh), safe area insets for notches and home indicators, and proper viewport meta tag configuration. </overview>
<viewport_meta>
Viewport Meta Tag
<configuration name="Standard">
<meta name="viewport" content="width=device-width, initial-scale=1.0">width=device-width: Match device widthinitial-scale=1.0: No initial zoom
</configuration>
<configuration name="With Safe Areas (Required for notch support)">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">viewport-fit=cover: Content extends into safe areas (under notch, home indicator)- Required for
env(safe-area-inset-*)to work
</configuration>
<anti_pattern name="Disabling Zoom">
<!-- DON'T DO THIS - Accessibility violation -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0">Why it's bad: Prevents users with vision impairments from zooming. iOS Safari ignores this anyway. </anti_pattern> </viewport_meta>
<viewport_units>
Viewport Units
<unit name="vh (Legacy)"> Problem: On mobile, 100vh equals the viewport height including browser chrome (address bar, bottom toolbar). This causes content to overflow when browser UI is visible.
/* BAD - will overflow on mobile Safari/Chrome */
.hero {
height: 100vh;
}</unit>
<unit name="svh (Small Viewport Height) - RECOMMENDED"> Represents the viewport height when ALL browser UI is visible (address bar expanded).
/* GOOD - consistent height, no overflow */
.hero {
height: 100vh; /* fallback for old browsers */
height: 100svh;
}Use for: Hero sections, landing pages, any "full screen" static content.
Support: iOS 15+, Chrome 108+, Firefox 101+ </unit>
<unit name="lvh (Large Viewport Height)"> Represents the viewport height when browser UI is minimized (address bar collapsed).
.expanded-view {
min-height: 100lvh;
}Use for: Rarely. Only when you want maximum possible height. </unit>
<unit name="dvh (Dynamic Viewport Height)"> Changes dynamically as browser UI appears/disappears.
/* Use sparingly - causes layout shifts */
.mobile-menu {
height: 100dvh;
}Use for: Full-screen modals, mobile menus that open at any scroll position.
Warning: Using dvh everywhere causes jarring layout shifts. Use for ~10% of cases.
Recommendation: Use svh for 90% of cases, dvh only for modals/menus. </unit>
<decision_tree>
Which Viewport Unit to Use?
Is this element always visible when page loads?
├── YES: Use svh
│ - Hero sections
│ - Landing pages
│ - Main content areas
│
└── NO: Does it need to fill screen at any scroll position?
├── YES: Use dvh
│ - Mobile navigation menus
│ - Full-screen modals
│ - Overlay dialogs
│
└── NO: Probably don't need vh units at all
- Use regular height/min-height
- Let content determine height</decision_tree>
<fallback_pattern>
Fallback Pattern
For browsers that don't support new units:
.full-height {
/* Fallback for old browsers */
height: 100vh;
/* Modern browsers will use this */
height: 100svh;
}
/* Or with @supports */
.full-height {
height: 100vh;
}
@supports (height: 100svh) {
.full-height {
height: 100svh;
}
}</fallback_pattern> </viewport_units>
<safe_areas>
Safe Area Insets
Safe areas account for:
- Top: Notch, Dynamic Island, status bar
- Bottom: Home indicator (iPhone X+), gesture area
- Left/Right: Curved screen edges, notch in landscape
<requirement> Prerequisite: Must have viewport-fit=cover in viewport meta tag:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">Without this, env(safe-area-inset-*) values are always 0. </requirement>
<usage name="Basic">
.header {
padding-top: env(safe-area-inset-top);
}
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
}</usage>
<usage name="With Minimum Padding">
/* Ensure minimum 1rem padding, but more if safe area requires */
.header {
padding-top: max(1rem, env(safe-area-inset-top));
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
.bottom-nav {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}</usage>
<usage name="Fixed Position Elements">
/* Fixed header */
.fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* Fixed bottom bar */
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}</usage>
<usage name="CSS Custom Properties (Recommended)">
:root {
--safe-top: env(safe-area-inset-top);
--safe-bottom: env(safe-area-inset-bottom);
--safe-left: env(safe-area-inset-left);
--safe-right: env(safe-area-inset-right);
}
.header {
padding-top: max(1rem, var(--safe-top));
}</usage>
<usage name="Tailwind CSS"> Add to your CSS or Tailwind config:
/* globals.css */
@layer utilities {
.pt-safe {
padding-top: env(safe-area-inset-top);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.pl-safe {
padding-left: env(safe-area-inset-left);
}
.pr-safe {
padding-right: env(safe-area-inset-right);
}
.px-safe {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.py-safe {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
}Usage:
<header class="pt-safe px-safe">...</header>
<nav class="pb-safe px-safe">...</nav></usage> </safe_areas>
<iphone_models>
iPhone Safe Area Values (Reference)
| Device | Top Inset | Bottom Inset |
|---|---|---|
| iPhone SE | 20px | 0px |
| iPhone 8 | 20px | 0px |
| iPhone X/XS | 44px | 34px |
| iPhone 11/12/13 | 47px | 34px |
| iPhone 14 Pro (Dynamic Island) | 59px | 34px |
| iPhone 14 Pro Max | 59px | 34px |
| iPhone 15/16 | Similar to 14 Pro | 34px |
Note: These are approximate. Always use env() rather than hardcoded values. </iphone_models>
<landscape>
Landscape Orientation
In landscape, safe areas change significantly:
- Left/Right insets become important (notch on side)
- Top inset reduces
- Bottom inset may change
/* Handle landscape notch */
.container {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
/* Orientation-specific styles */
@media (orientation: landscape) {
.header {
padding-top: env(safe-area-inset-top);
padding-left: max(2rem, env(safe-area-inset-left));
padding-right: max(2rem, env(safe-area-inset-right));
}
}</landscape>
<common_mistakes>
Common Mistakes
<mistake name="Forgetting viewport-fit=cover"> Safe area insets return 0 without it. </mistake>
<mistake name="Using px instead of env()"> Don't hardcode safe area values - they vary by device. </mistake>
<mistake name="Only handling portrait"> Test in landscape - notch moves to side. </mistake>
<mistake name="Ignoring bottom safe area"> Home indicator area is significant on iPhone X+. </mistake>
<mistake name="Using 100vh for app shell"> Use 100svh or calc with safe areas. </mistake> </common_mistakes>
Workflow: Audit Mobile App/PWA
<required_reading> Read these reference files before auditing: 1. references/ios-safari-quirks.md 2. references/viewport-layout.md 3. references/pwa-manifest.md 4. references/performance.md </required_reading>
<process>
Step 1: Run Automated Audits
Run Lighthouse in Chrome DevTools:
1. Open Chrome DevTools (Cmd+Opt+I / Ctrl+Shift+I)
2. Go to Lighthouse tab
3. Select: Mobile, Performance, Accessibility, Best Practices, SEO, PWA
4. Click "Analyze page load"Check specific scores:
- Performance: Should be 90+
- PWA: Should pass all checks
- Accessibility: Should be 90+
Step 2: Check Viewport Configuration
Verify viewport meta tag in <head>:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">Red flags:
- Missing
viewport-fit=cover(safe areas won't work) user-scalable=no(accessibility violation)maximum-scale=1(accessibility violation on iOS)
Step 3: Audit CSS for Mobile Issues
Search for problematic patterns:
# Find 100vh usage (potential mobile overflow)
grep -r "100vh" --include="*.css" --include="*.tsx" --include="*.jsx"
# Find fixed positioning (may conflict with safe areas)
grep -r "position:\s*fixed" --include="*.css" --include="*.tsx"
# Find small font sizes
grep -rE "font-size:\s*(0\.[0-9]+rem|[0-9]px|1[0-1]px)" --include="*.css"Check for:
- [ ] Uses
svh/dvhinstead ofvhfor full-height elements - [ ] Safe area insets applied to fixed headers/footers
- [ ] Touch targets are 44px minimum
- [ ] No horizontal overflow (test by scrolling sideways)
Step 4: Audit PWA Configuration
Check manifest.json:
# Find manifest
find . -name "manifest*.json" -o -name "manifest.ts"Required manifest properties:
- [ ]
nameandshort_name(under 12 chars) - [ ]
start_urlset to app entry point - [ ]
display: "standalone"for app-like experience - [ ]
theme_colormatches app branding - [ ]
background_colormatches splash screen - [ ] Icons: 192x192 and 512x512 minimum
- [ ] Maskable icon with
purpose: "maskable"
Step 5: Audit Service Worker
Check service worker registration:
# Find service worker
find . -name "sw.js" -o -name "service-worker.js" -o -name "*sw*.ts"Verify:
- [ ] Service worker registers successfully
- [ ] Caching strategy appropriate for content type
- [ ] Offline fallback page exists
- [ ] Cache versioning for updates
Step 6: Test on Real iOS Device
Critical iOS-specific checks:
- [ ] Safe areas respected (check in landscape)
- [ ] No 100vh overflow issues
- [ ] Touch targets large enough
- [ ] No 300ms tap delay
- [ ] Rubber band scroll behavior acceptable
- [ ] PWA installs from Safari
- [ ] Status bar style correct
- [ ] Splash screen displays
To test: 1. Connect iPhone to Mac 2. Open Safari on iPhone, navigate to your app 3. Open Safari on Mac → Develop → [Your iPhone] → [Your page] 4. Use Web Inspector to debug
Step 7: Test on Android
Android-specific checks:
- [ ] Install prompt appears (if criteria met)
- [ ] Theme color applies to address bar
- [ ] Splash screen renders correctly
- [ ] Push notifications work (if implemented)
Step 8: Generate Audit Report
Create report with findings:
## Mobile/PWA Audit Results
### Scores
- Lighthouse Performance: X/100
- Lighthouse PWA: X/100
- Lighthouse Accessibility: X/100
### Critical Issues
1. [Issue]: [Description]
- File: [path]
- Fix: [recommended action]
### Warnings
1. [Warning]: [Description]
### Passed Checks
- [ ] Viewport configured correctly
- [ ] Safe areas handled
- [ ] PWA installable
- [ ] Service worker active
- [ ] Touch targets adequate</process>
<anti_patterns> Avoid:
- Testing only in Chrome DevTools emulator
- Ignoring safe area insets
- Using
100vhfor mobile layouts - Small touch targets (< 44px)
- Missing maskable icons
- Hardcoding colors that should come from manifest
</anti_patterns>
<success_criteria> Audit is complete when:
- [ ] Lighthouse audit run and scores recorded
- [ ] Viewport and CSS issues identified
- [ ] PWA configuration verified
- [ ] Service worker checked
- [ ] Tested on real iOS device
- [ ] Tested on Android device
- [ ] Report generated with prioritized fixes
</success_criteria>
Workflow: Fix iOS Safari Issues
<required_reading> Read these reference files before fixing: 1. references/ios-safari-quirks.md 2. references/viewport-layout.md 3. references/touch-interactions.md </required_reading>
<process>
Step 1: Identify the iOS Issue Category
Common iOS Safari issues:
| Symptom | Likely Cause | Reference |
|---|---|---|
| Page overflows vertically | 100vh includes browser chrome | viewport-layout.md |
| Content behind notch | Missing safe area insets | viewport-layout.md |
| Tap delay feels slow | Missing touch-action | touch-interactions.md |
| Rubber band bounce annoying | Missing overscroll-behavior | ios-safari-quirks.md |
| PWA won't install | Manifest or HTTPS issues | pwa-manifest.md |
| Storage getting cleared | 7-day cap for non-installed PWAs | ios-safari-quirks.md |
| Push notifications not working | PWA not installed to home screen | push-notifications.md |
| Fixed element jumps around | Keyboard or scroll issues | ios-safari-quirks.md |
Step 2: Fix Viewport Height Issues
Problem: 100vh includes the Safari address bar, causing overflow.
Solution: Use new viewport units:
/* Replace 100vh with svh for static full-height elements */
.hero {
min-height: 100vh; /* fallback for old browsers */
min-height: 100svh;
}
/* Use dvh ONLY for elements that should resize with browser chrome */
.mobile-menu {
height: 100dvh;
}Decision tree:
- Static hero/landing page →
100svh - Full-screen modal that opens at any scroll position →
100dvh - Element that should fill available space →
100svh
Step 3: Fix Safe Area Issues
Problem: Content hidden behind notch, Dynamic Island, or home indicator.
Step 3.1: Enable viewport-fit in HTML:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">Step 3.2: Apply safe area padding:
/* Header - account for notch/Dynamic Island */
.header {
padding-top: env(safe-area-inset-top);
/* Or with minimum padding */
padding-top: max(1rem, env(safe-area-inset-top));
}
/* Bottom navigation - account for home indicator */
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
}
/* Full-width elements in landscape */
.container {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}Tailwind CSS approach:
/* In globals.css or tailwind config */
@supports (padding: env(safe-area-inset-top)) {
.safe-top { padding-top: env(safe-area-inset-top); }
.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }
.safe-left { padding-left: env(safe-area-inset-left); }
.safe-right { padding-right: env(safe-area-inset-right); }
}Step 4: Fix Tap Delay
Problem: 300ms delay on taps feels sluggish.
Solution: Apply touch-action CSS:
/* Apply to all interactive elements */
a, button, input, select, textarea, label, summary, [role="button"] {
touch-action: manipulation;
}
/* Or globally (less targeted) */
* {
touch-action: manipulation;
}Step 5: Fix Scroll Issues
Problem: Rubber band bounce scrolling, scroll locking for modals.
For rubber band bounce:
html {
overscroll-behavior: none;
}For modal scroll lock (iOS-safe approach):
// When opening modal
document.body.style.position = 'fixed';
document.body.style.top = `-${window.scrollY}px`;
document.body.style.width = '100%';
// When closing modal
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);Step 6: Fix Status Bar Styling
For standalone PWA:
<!-- Required for standalone mode -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar options: default, black, black-translucent -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">Note: black-translucent is deprecated but still works. It makes content extend under status bar.
Step 7: Fix iOS-Specific CSS Bugs
Flexbox inconsistencies:
/* Be explicit with flex properties */
.flex-container {
display: flex;
flex-direction: row; /* explicit, not shorthand */
min-width: 0; /* prevent flex item overflow */
}
.flex-item {
flex: 0 0 auto; /* explicit basis */
min-width: 0;
}Position: fixed with keyboard:
/* Fixed elements that should stay visible with keyboard */
.input-toolbar {
position: fixed;
bottom: 0;
/* iOS keyboard pushes fixed elements, use this to adjust */
bottom: env(keyboard-inset-height, 0);
}Step 8: Verify Fixes
Test on real iOS device: 1. Connect iPhone to Mac 2. Open Safari → Your app 3. Safari on Mac → Develop → [iPhone] → [Page] 4. Test in:
- Portrait and landscape
- With and without keyboard open
- As PWA installed to home screen
- In Safari browser (not installed)
</process>
<anti_patterns> Avoid:
- Using JavaScript libraries like FastClick (obsolete since iOS 9.3)
- Disabling zoom with
user-scalable=no(accessibility violation) - Using
constant()instead ofenv()for safe areas (deprecated) - Testing only in Chrome DevTools
- Assuming iOS behavior matches Android
</anti_patterns>
<success_criteria> iOS issues are fixed when:
- [ ] No vertical overflow from viewport height
- [ ] Safe areas respected on all iPhone models
- [ ] No perceptible tap delay
- [ ] Scroll behavior is smooth and controlled
- [ ] PWA status bar styled correctly
- [ ] All fixes verified on real iOS device
</success_criteria>
Workflow: Optimize Mobile Performance
<required_reading> Read these reference files before optimizing: 1. references/performance.md 2. references/image-optimization.md </required_reading>
<process>
Step 1: Measure Current Performance
Run Lighthouse audit:
DevTools → Lighthouse → Mobile → PerformanceRecord baseline metrics:
- LCP (Largest Contentful Paint): Should be < 2.5s
- INP (Interaction to Next Paint): Should be < 200ms
- CLS (Cumulative Layout Shift): Should be < 0.1
Also check:
- Total blocking time
- First contentful paint
- Speed index
Step 2: Optimize Largest Contentful Paint (LCP)
73% of mobile pages have an image as LCP element.
Identify LCP Element
DevTools → Performance → Record page load → Look for "LCP" markerImage LCP Optimization
Use Next.js Image component:
import Image from 'next/image'
// For LCP images, add priority prop
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Disables lazy loading, preloads image
sizes="100vw" // Tell browser expected size
/>Enable modern formats in next.config.js:
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
}Preload critical images:
// In layout.tsx or page.tsx metadata
export const metadata = {
other: {
'link': [
{ rel: 'preload', as: 'image', href: '/hero.webp' }
]
}
}Text LCP Optimization
If LCP is text, optimize fonts:
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent FOIT
preload: true,
})Step 3: Optimize Interaction to Next Paint (INP)
INP replaced FID in March 2024.
Identify Slow Interactions
DevTools → Performance → Record interactions → Look for long tasksReduce JavaScript Execution
Code split with dynamic imports:
import dynamic from 'next/dynamic'
// Lazy load heavy components
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false,
})Defer non-critical scripts:
<Script
src="https://analytics.example.com"
strategy="lazyOnload"
/>Use React transitions for heavy updates:
import { useTransition } from 'react'
function SearchResults() {
const [isPending, startTransition] = useTransition()
function handleSearch(query: string) {
startTransition(() => {
setSearchResults(expensiveSearch(query))
})
}
}Optimize Event Handlers
Debounce scroll/resize handlers:
import { useDebouncedCallback } from 'use-debounce'
const handleScroll = useDebouncedCallback(() => {
// Heavy operation
}, 100)Step 4: Optimize Cumulative Layout Shift (CLS)
Reserve Space for Dynamic Content
Images - always specify dimensions:
<Image
src="/photo.jpg"
width={400}
height={300}
alt="Photo"
/>Skeleton loaders for async content:
{isLoading ? (
<div className="h-48 w-full animate-pulse bg-muted rounded" />
) : (
<ActualContent />
)}Avoid Layout-Triggering Animations
Bad - causes reflow:
.animate {
animation: slide 0.3s;
}
@keyframes slide {
from { margin-left: -100px; }
to { margin-left: 0; }
}Good - GPU accelerated:
.animate {
animation: slide 0.3s;
}
@keyframes slide {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}Handle Dynamic Ads/Embeds
Reserve fixed space:
<div className="min-h-[250px]"> {/* Standard ad height */}
<AdComponent />
</div>Step 5: Optimize for Mobile Networks
Enable Compression
In next.config.js:
module.exports = {
compress: true,
}Minimize Bundle Size
Analyze bundle:
pnpm add -D @next/bundle-analyzer
ANALYZE=true pnpm buildRemove unused dependencies:
npx depcheckUse CDN for Static Assets
Configure in next.config.js:
module.exports = {
assetPrefix: 'https://cdn.example.com',
}Step 6: Implement Performance Monitoring
Use web-vitals library:
// app/layout.tsx or a client component
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric) // or send to analytics
})
return null
}Step 7: Test on Real Mobile Hardware
Chrome DevTools throttling doesn't match real devices:
1. Test on mid-range Android device (not just flagship) 2. Test on 3G connection 3. Use Chrome's "Slow 3G" preset as minimum bar 4. Check Lighthouse in "Applied Slow 4G" mode </process>
<anti_patterns> Avoid:
- Inlining large images as base64 (blocks rendering)
- Not specifying image dimensions (causes CLS)
- Loading all components eagerly (hurts INP)
- Using layout-triggering CSS properties for animations
- Testing only on fast devices/connections
- Ignoring INP (new metric many overlook)
</anti_patterns>
<success_criteria> Performance is optimized when:
- [ ] LCP < 2.5 seconds on mobile
- [ ] INP < 200ms
- [ ] CLS < 0.1
- [ ] Lighthouse Performance score > 90
- [ ] Images use next/image with proper sizes
- [ ] Heavy components are code-split
- [ ] No render-blocking resources
- [ ] Tested on real mobile device with slow connection
</success_criteria>
Workflow: Set Up PWA
<required_reading> Read these reference files before setup: 1. references/pwa-manifest.md 2. references/service-workers.md 3. references/ios-safari-quirks.md </required_reading>
<process>
Step 1: Create Web App Manifest
For Next.js 14+, create app/manifest.ts (or manifest.json in public/):
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Your App Name',
short_name: 'AppName', // Max 12 characters
description: 'Your app description',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}Step 2: Generate PWA Icons
Required icon sizes:
- 192x192 (required for Chrome)
- 512x512 (required for Chrome)
- 180x180 (Apple Touch Icon)
- Maskable version (for Android adaptive icons)
Generate with:
# Using pwa-asset-generator (recommended)
npx pwa-asset-generator ./logo.png ./public/icons --index ./app/layout.tsxOr manually create:
/public/icons/icon-192.png/public/icons/icon-512.png/public/icons/icon-maskable-512.png/public/icons/apple-touch-icon.png(180x180)
Step 3: Add Meta Tags for iOS
In your layout.tsx or _document.tsx:
// app/layout.tsx
export const metadata: Metadata = {
// ... other metadata
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Your App',
},
formatDetection: {
telephone: false, // Prevent auto-linking phone numbers
},
}
// Or in head
<>
<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="Your App" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
</>Step 4: Create Service Worker
Option A: Next.js built-in (simple, recommended for 2025)
Next.js 14+ supports PWA natively. Add to next.config.js:
// next.config.js
const nextConfig = {
// Enable PWA features
experimental: {
// If using App Router, this is typically sufficient
},
}Create a basic service worker at public/sw.js:
const CACHE_NAME = 'app-v1';
const OFFLINE_URL = '/offline';
// Assets to cache immediately
const PRECACHE_ASSETS = [
'/',
'/offline',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Network-first for HTML, cache-first for assets
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL);
})
);
} else {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
}
});Option B: Using Serwist (next-pwa successor)
pnpm add @serwist/next// next.config.js
const withSerwist = require("@serwist/next").default({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
});
module.exports = withSerwist({
// Your Next.js config
});Step 5: Register Service Worker
// components/ServiceWorkerRegistration.tsx
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope)
})
.catch((error) => {
console.log('SW registration failed:', error)
})
}
}, [])
return null
}
// Add to layout.tsx
<ServiceWorkerRegistration />Step 6: Create Offline Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-svh items-center justify-center p-4">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">You're Offline</h1>
<p className="text-muted-foreground">
Please check your internet connection and try again.
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded"
>
Retry
</button>
</div>
</div>
)
}Step 7: iOS Splash Screens (Optional)
iOS requires specific splash screen images. Generate with:
npx pwa-asset-generator ./logo.png ./public/splash \
--splash-only \
--type png \
--index ./app/layout.tsxThis generates <link rel="apple-touch-startup-image"> tags for all iOS screen sizes.
Step 8: Test PWA Installation
Chrome (Android/Desktop): 1. Open DevTools → Application → Manifest 2. Check for errors 3. Look for "Install" button in address bar
Safari (iOS): 1. Open your app in Safari 2. Tap Share button 3. Tap "Add to Home Screen" 4. Verify icon and name appear correctly 5. Open from home screen - should be in standalone mode
Lighthouse PWA Audit: 1. DevTools → Lighthouse 2. Check "Progressive Web App" 3. Generate report 4. Fix any failing checks </process>
<anti_patterns> Avoid:
- Using deprecated
apple-mobile-web-app-capablewithout manifest - Forgetting maskable icons (Android adaptive icons look bad)
- Large service worker precache (keep under 50MB for iOS)
- Not versioning your cache (causes stale content)
- Using next-pwa (unmaintained since July 2024)
</anti_patterns>
<success_criteria> PWA setup is complete when:
- [ ] Manifest loads without errors in DevTools
- [ ] All required icons present and loading
- [ ] Service worker registers and activates
- [ ] Offline page works when disconnected
- [ ] App installs from Chrome address bar
- [ ] App installs from Safari "Add to Home Screen"
- [ ] Lighthouse PWA audit passes all checks
- [ ] Splash screen displays on iOS
</success_criteria>
Workflow: Test Mobile Compatibility
<required_reading> Read these reference files before testing: 1. references/testing-debugging.md 2. references/ios-safari-quirks.md </required_reading>
<process>
Step 1: Automated Testing with Lighthouse
Run Lighthouse audit:
DevTools → Lighthouse → Select "Mobile" → Check all categories → AnalyzeKey scores to check:
- Performance: 90+ target
- Accessibility: 90+ target
- Best Practices: 90+ target
- SEO: 90+ target
- PWA: All checks should pass
Save report for comparison:
# CLI option for CI/CD
npx lighthouse https://your-app.com --preset=perf --output=html --output-path=./lighthouse-report.htmlStep 2: Chrome DevTools Mobile Simulation
Enable device mode:
DevTools → Toggle Device Toolbar (Cmd+Shift+M / Ctrl+Shift+M)Test these viewports:
- iPhone SE (375×667) - smallest common iPhone
- iPhone 14 Pro (393×852) - with Dynamic Island
- iPhone 14 Pro Max (430×932) - largest iPhone
- Pixel 7 (412×915) - common Android
- iPad Mini (768×1024) - tablet breakpoint
- iPad Pro (1024×1366) - large tablet
Test orientations:
- Portrait and landscape for each device
Simulate network conditions:
DevTools → Network tab → Throttling dropdown → Slow 3GImportant: DevTools simulation does NOT accurately represent:
- iOS Safari CSS bugs
- Real touch behavior
- Safe area insets
- Address bar behavior
- Actual performance on mobile hardware
Step 3: Test on Real iOS Device
Option A: Direct USB connection (recommended) 1. Connect iPhone to Mac with cable 2. iPhone: Settings → Safari → Advanced → Web Inspector ON 3. Open Safari on iPhone, navigate to your app 4. Mac: Safari → Develop → [Your iPhone] → [Your page] 5. Use Web Inspector to debug
Option B: Remote debugging via ngrok/localtunnel
# Start local dev server
pnpm dev
# In another terminal, expose localhost
npx ngrok http 3000
# or
npx localtunnel --port 3000Open the provided URL on your iPhone.
iOS-specific tests:
- [ ] Safe areas on iPhone with notch/Dynamic Island
- [ ] Safe areas in landscape orientation
- [ ] 100vh/svh behavior - no overflow
- [ ] Touch targets are 44pt minimum
- [ ] Tap responsiveness (no 300ms delay)
- [ ] Keyboard behavior with fixed elements
- [ ] Rubber band scroll at page boundaries
- [ ] PWA installation from Safari share menu
- [ ] PWA opens in standalone mode
- [ ] PWA splash screen appears
- [ ] Status bar style correct in PWA mode
Step 4: Test on Real Android Device
Option A: Chrome Remote Debugging 1. Android: Settings → Developer Options → USB Debugging ON 2. Connect Android to computer with cable 3. Open Chrome on Android, navigate to your app 4. Computer: chrome://inspect → Click "inspect" next to your page
Option B: ADB over WiFi
adb connect <device-ip>:5555Then use chrome://inspect.
Android-specific tests:
- [ ] Theme color applies to address bar
- [ ] Install prompt appears (if criteria met)
- [ ] PWA installs with correct icon
- [ ] PWA splash screen correct
- [ ] Push notifications work (if implemented)
- [ ] Back button behavior in PWA
- [ ] WebAPK installation successful
Step 5: Cross-Browser Testing
Browsers to test:
- Safari (iOS) - uses WebKit
- Chrome (iOS) - also uses WebKit on iOS!
- Chrome (Android) - Chromium
- Samsung Internet - Chromium-based
- Firefox (Android) - Gecko
Use BrowserStack or Sauce Labs for:
- Older iOS versions (14, 15, 16)
- Older Android versions
- Specific device models you don't own
Step 6: Accessibility Testing
Automated:
DevTools → Lighthouse → Accessibility auditManual on iOS: 1. Settings → Accessibility → VoiceOver → ON 2. Navigate your app with VoiceOver 3. Ensure all interactive elements are announced
Manual on Android: 1. Settings → Accessibility → TalkBack → ON 2. Navigate your app with TalkBack 3. Check touch target sizes (48dp minimum for Android)
Step 7: Performance Testing on Real Devices
Why real device testing matters:
- DevTools throttling doesn't match real mobile CPUs
- Real devices have memory constraints
- Touch responsiveness differs
- GPU capabilities vary
Test on mid-range device: Don't just test on flagship phones. Use:
- iPhone SE (2nd/3rd gen) for iOS
- Budget Android device (< $300) for Android
Measure real metrics:
// Add to your app to log real-user metrics
import { onLCP, onINP, onCLS } from 'web-vitals'
onLCP(console.log)
onINP(console.log)
onCLS(console.log)Step 8: PWA-Specific Testing
Service Worker Testing:
DevTools → Application → Service Workers- [ ] Service worker registered and activated
- [ ] No errors in console
- [ ] Cache storage contains expected files
- [ ] "Update on reload" works for testing
Offline Testing:
DevTools → Network → Offline checkbox- [ ] Offline page displays
- [ ] Cached pages still load
- [ ] Meaningful offline experience
Installation Testing:
DevTools → Application → Manifest- [ ] No manifest errors
- [ ] Icons loading correctly
- [ ] Install prompt appears (Chrome)
- [ ] Installable on iOS via Share menu
</process>
<anti_patterns> Avoid:
- Testing only in Chrome DevTools emulator
- Skipping real iOS device testing
- Only testing on flagship devices
- Ignoring landscape orientation
- Not testing with keyboard open
- Assuming Android behavior matches iOS
- Skipping accessibility testing
</anti_patterns>
<success_criteria> Mobile testing is complete when:
- [ ] Lighthouse scores recorded for all categories
- [ ] Tested on at least one real iOS device
- [ ] Tested on at least one real Android device
- [ ] Tested in multiple browsers
- [ ] Safe areas verified in portrait and landscape
- [ ] Touch interactions verified
- [ ] PWA installation verified on both platforms
- [ ] Offline behavior verified
- [ ] Accessibility tested with screen reader
- [ ] Performance tested on mid-range device
</success_criteria>