iOS Modal Transparency Playbook
This guide captures the fixes we applied to stop Safari on iPhone/iPad from turning modals, drawers, and flyouts transparent after scrolling. Follow it whenever you ship a new overlay.
Quick Rules
- Portal overlays to
document.body. No ancestors are allowed to usetransform,filter,opacity,perspective,will-change, orcontain. - Use background alpha, never parent opacity. Apply
background-color: rgba(...)on the overlay element; do not fade the container withopacity. position: fixed,inset: 0, big z-index. Keep the overlay firmly pinned and on top.- Backdrop and content are separate siblings. The blur/dim layer lives behind, the dialog lives above; never combine them.
- Scroll-lock the body with the fixed-top technique. Capture
scrollY, setbodytoposition: fixed, restore on close. - Avoid
-webkit-overflow-scrolling: touchon overlay ancestors. It breaksposition: fixed. - Prefer
dvh(100dvh) with a100vhfallback. - Only the overlay gets
translateZ(0). Do not scatterwill-changeor 3D transforms across the app shell.
Minimal CSS
/* Base document safety */
html, body { height: 100%; }
body.lock-scroll {
position: fixed;
width: 100%;
overflow: hidden;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
height: 100dvh;
}
@supports not (height: 100dvh) {
.modal-overlay { height: 100vh; }
}
.modal-backdrop {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
pointer-events: none;
}
.modal-content {
position: relative;
margin: 0 auto;
max-width: 640px;
background: #ffffff;
border-radius: 16px;
-webkit-font-smoothing: antialiased;
transform: translateZ(0);
}
Tailwind Equivalents
- Overlay:
fixed inset-0 z-[9999] bg-black/50 [transform:translateZ(0)] - Backdrop:
absolute inset-0 bg-black/30 backdrop-blur pointer-events-none - Content:
relative mx-auto max-w-[640px] rounded-2xl bg-white
React Portal & Scroll Lock
// Modal.tsx
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export default function Modal({ open, onClose, children }) {
const elRef = useRef<HTMLDivElement | null>(null);
if (!elRef.current) elRef.current = document.createElement("div");
useEffect(() => {
const el = elRef.current!;
el.className = "modal-root";
document.body.appendChild(el);
return () => document.body.removeChild(el);
}, []);
useEffect(() => {
if (!open) return;
const scrollY = window.scrollY || window.pageYOffset;
document.body.classList.add("lock-scroll");
document.body.style.top = `-${scrollY}px`;
return () => {
document.body.classList.remove("lock-scroll");
const { top } = document.body.style;
document.body.style.top = "";
window.scrollTo(0, top ? -parseInt(top, 10) : 0);
};
}, [open]);
if (!open) return null;
const overlay = (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-backdrop" />
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
return createPortal(overlay, elRef.current!);
}
iOS-Specific Hardening
@supports (-webkit-touch-callout: none) {
.modal-overlay {
z-index: 2147483647;
}
.modal-overlay,
.modal-content {
-webkit-overflow-scrolling: auto !important;
}
}
- Make sure no ancestor (including the app shell) uses
transform,filter,opacity < 1,perspective,mix-blend-mode, orwill-changewhenever the modal is open. - If the shell needs transforms, mount the modal outside it.
Animation Guidelines
- Animate
opacity/transformon.modal-contentonly. - Keep durations short (≤ 240 ms).
- Do not animate overlay background colors; stick to fade/slide on the dialog.
- If you need blur, keep it on
.modal-backdrop, never on the content wrapper.
Pitfall Checklist
- Overlay is appended directly to
document.body. - No transformed/filtered ancestors above the overlay.
- Overlay uses
background-color: rgba(...)(no containeropacity). - Overlay is
position: fixed; inset: 0; z-index≥ 9999 (or 2147483647 on iOS). - Body scroll is locked via the fixed-top technique.
- No
-webkit-overflow-scrolling: touchon overlay or its ancestors. - Using
height: 100dvhwith a100vhfallback. - Overlay has
translateZ(0); no straywill-changeelsewhere. - Backdrop blur (if used) is a sibling layer.
- Animations live on the dialog content only.
Troubleshooting
- Toggle the overlay compositing: remove
translateZ(0); if unchanged, reapply. - Temporarily drop
backdrop-filterto see if blur is the trigger. - Remove nested momentum scrolling (
-webkit-overflow-scrolling: auto). - Check z-index collisions (toasts, toolbars, etc.).
- Remote Inspect via Safari → Layers panel: overlay must have its own composited layer.
<dialog> Notes
- Still portal the element and polyfill
showModal()on iOS. - Keep the custom
.modal-backdropelement; do not rely solely on::backdrop.
Tailwind Drop-In Snippet
<!-- Rendered under <body> via portal -->
<div class="fixed inset-0 z-[2147483647] bg-black/50 [transform:translateZ(0)] h-[100dvh]">
<div class="absolute inset-0 bg-black/30 backdrop-blur pointer-events-none"></div>
<div class="relative mx-auto mt-8 max-w-[640px] rounded-2xl bg-white p-6">
<!-- modal content -->
</div>
</div>
Remember to add/remove lock-scroll on <body> as shown in the React example.