Last updated: Nov 17, 2025, 05:25 PM UTC

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 use transform, filter, opacity, perspective, will-change, or contain.
  • Use background alpha, never parent opacity. Apply background-color: rgba(...) on the overlay element; do not fade the container with opacity.
  • 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, set body to position: fixed, restore on close.
  • Avoid -webkit-overflow-scrolling: touch on overlay ancestors. It breaks position: fixed.
  • Prefer dvh (100dvh) with a 100vh fallback.
  • Only the overlay gets translateZ(0). Do not scatter will-change or 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, or will-change whenever the modal is open.
  • If the shell needs transforms, mount the modal outside it.

Animation Guidelines

  • Animate opacity / transform on .modal-content only.
  • 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 container opacity).
  • 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: touch on overlay or its ancestors.
  • Using height: 100dvh with a 100vh fallback.
  • Overlay has translateZ(0); no stray will-change elsewhere.
  • 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-filter to 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-backdrop element; 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.