Fixing Invisible PWA Install Modals on Vite, React, and Vercel
In a React community I participate in, a developer recently ran into a frustrating issue: their custom PWA “Install Our App” modal worked perfectly on localhost, but once deployed to Vercel using Vite and Tailwind CSS, the modal simply refused to appear. There were no console errors, and the UI code seemed intact.
If you are facing this, the issue is rarely a “broken” modal. Instead, it is usually a failure of the browser’s PWA criteria or a CSS stacking context issue triggered by the production build process.
The Short Answer (For Experienced Devs)
Section titled “The Short Answer (For Experienced Devs)”The “invisibility” is typically caused by one of two things:
- The Event Didn’t Fire: The
beforeinstallpromptevent never triggers in production because your Service Worker isn’t successfully registering or themanifest.jsonhas a pathing error (common with Vite’s/publicfolder). - Tailwind JIT/Purge: In production, Tailwind may have purged the z-index or opacity classes if they were constructed using string interpolation (e.g.,
z-[${index}]), or the modal is trapped behind a new stacking context created by a production-only wrapper.
Deep Dive: Why It Happens and How to Fix It
Section titled “Deep Dive: Why It Happens and How to Fix It”Solution 1: Validating the PWA Criteria (Logic Fix)
Section titled “Solution 1: Validating the PWA Criteria (Logic Fix)”Browsers like Chrome will only fire the beforeinstallprompt event if the site meets strict PWA criteria. Often, local dev environments bypass some of these checks, but Vercel’s HTTPS environment enforces them strictly.
If the event doesn’t fire, your React state (e.g., showModal) stays false.
The Fix (React 18 + Vite PWA Plugin):
Ensure your vite-plugin-pwa is configured correctly to generate the manifest and service worker in the build.
// vite.config.js (Vite 5.x)import { defineConfig } from 'vite';import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ plugins: [ VitePWA({ registerType: 'autoUpdate', manifest: { name: 'My Awesome App', short_name: 'App', start_url: '/', // Ensure this matches your Vercel deployment root display: 'standalone', icons: [ { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' } ] } }) ]});The Logic Hook:
Use a robust hook to capture the event. Note that beforeinstallprompt is not a standard TypeScript event, so you may need to extend the Window interface.
// usePwaInstall.ts (Illustrative example — verify in your environment)import { useState, useEffect } from 'react';
export const usePwaInstall = () => { const [installPrompt, setInstallPrompt] = useState<any>(null);
useEffect(() => { const handler = (e: Event) => { e.preventDefault(); console.log("PWA Install Prompt Captured"); setInstallPrompt(e); };
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler); }, []);
return installPrompt;};Solution 2: Tailwind Stacking Context & React Portals (CSS Fix)
Section titled “Solution 2: Tailwind Stacking Context & React Portals (CSS Fix)”If the console log “PWA Install Prompt Captured” appears but you see nothing, it is a CSS issue. Production builds often minify and reorder CSS, which can change how z-index behaves if you aren’t using a Stacking Context correctly.
The Fix (Tailwind CSS 3.x + React 18):
Instead of rendering the modal inside your main layout (where it might be clipped by an overflow-hidden container), use a React Portal. This moves the modal to the bottom of the <body> tag, ensuring it isn’t buried by other elements.
// InstallModal.jsx (React 18)import { createPortal } from 'react-dom';
const InstallModal = ({ isOpen, onInstall, onClose }) => { if (!isOpen) return null;
return createPortal( <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"> <div className="bg-white p-6 rounded-lg shadow-xl dark:bg-slate-800"> <h2 className="text-xl font-bold">Install our App</h2> <p>Get a better experience by installing our app on your home screen.</p> <div className="mt-4 flex gap-2"> <button onClick={onInstall} className="bg-blue-600 text-white px-4 py-2 rounded" > Install </button> <button onClick={onClose} className="px-4 py-2">Close</button> </div> </div> </div>, document.body );};Why this works:
By attaching to document.body, you bypass any relative or absolute positioning on the parent containers in your React tree that might be setting a lower z-index.
Prevention Checklist
Section titled “Prevention Checklist”- Check the Manifest Path: Open Chrome DevTools -> Application -> Manifest. If there are errors here, the “Install” event will never fire on Vercel.
- Service Worker Active: Check Application -> Service Workers. Ensure it is “Activated and Running”.
- Static Assets: Ensure your PWA icons are in the
publicfolder. If they are 404ing, the PWA is considered “invalid” by the browser. - Tailwind Purge: Avoid dynamic class names like
z-[${myIndex}]. Use full class names likez-50orz-[9999]so the Tailwind compiler doesn’t strip them during the production build on Vercel.
Follow-up Concerns
Section titled “Follow-up Concerns”Does this work on iOS Safari?
No. iOS Safari does not support the beforeinstallprompt event. For iOS, the modal is “invisible” because the event never fires. You must detect the browser agent and show a “manual” guide (e.g., “Tap the Share icon and then Add to Home Screen”).
How can I debug this without deploying to Vercel constantly?
You can use a tool like serve to run your production build locally. Run npm run build and then npx serve dist. This replicates the production environment much more closely than vite dev.
What if the modal shows once and then never again?
The browser often suppresses the beforeinstallprompt if the user has dismissed it recently. You can clear your “Site Data” in the Application tab of DevTools to reset this behavior for testing.