Google Places Autocomplete Is Completely Broken Inside Radix Modals — Here's the 3-Part Fix
TL;DR: Google Places Autocomplete silently breaks inside Radix UI dialogs — the dropdown renders in the DOM but is invisible, unclickable, and locked with
inert. One fix isn't enough. You need three: a CSS override, arequestAnimationFrameloop, and anonInteractOutsideguard. Complete copy-paste solution at the bottom.
The Bug That Will Make You Question Your Sanity
You wire up @react-google-maps/api Autocomplete inside a Radix Dialog. Standard stuff. You open the modal, start typing an address, and wait for the familiar dropdown.
It never comes.
You open DevTools. The .pac-container is there — sitting perfectly in the DOM, fully populated with suggestions. But it's invisible. Unclickable. Slapped with an inert attribute like it insulted someone.
This isn't a misconfiguration. It isn't a version mismatch. It's a three-layer collision between how Radix locks down its modal environment and how Google Places injects its dropdown into the page. Every layer breaks something different. Every layer needs its own fix.
I burned hours on this. Stack Overflow has fragments of answers. None of them work completely. Here's the full production fix.
Why This Happens — 3 Layers Deep
When modal={true} (the default), Radix Dialog does three things that independently destroy Google Places:
Layer 1 — pointer-events: none on <body>
Radix's DismissableLayer sets pointer-events: none on the entire <body> to ensure clicks outside the dialog trigger dismissal. Google's .pac-container is appended as a direct child of <body> — completely outside the dialog portal. It inherits pointer-events: none and becomes untouchable.
Layer 2 — inert + aria-hidden via MutationObserver
Radix uses the aria-hidden package to enforce accessibility isolation. It sets aria-hidden="true" and the HTML inert attribute on every direct child of <body> that is not the active dialog portal.
The brutal part: it uses its own MutationObserver to continuously re-apply these attributes whenever new children are added to <body>. Google Places appends .pac-container dynamically when suggestions load. The observer sees it, and within milliseconds, locks it down again. You can't out-run it with a one-time fix.
Layer 3 — "Outside" Click Dismissal
Even if you get past layers 1 and 2, there's a third trap. When the user clicks a suggestion inside .pac-container, Radix sees a pointer event originating outside the dialog bounds. It interprets this as an outside interaction and closes the modal before the selection can register. The modal snaps shut. Nothing is selected. The user has to start over.
The 3-Part Fix
Part 1 — CSS: Restore Visibility and Pointer Events
/* globals.css */
/*
* Fix: Google Places Autocomplete inside Radix UI modal dialogs.
*
* z-index: 9999 renders the dropdown above the Radix overlay (z-50).
* pointer-events: auto overrides the body-level pointer-events: none.
*/
.pac-container {
z-index: 9999 !important;
pointer-events: auto !important;
}
This handles Layer 1. The dropdown is now visible and technically interactive — but still frozen with inert. On its own, this fix accomplishes nothing useful.
Part 2 — requestAnimationFrame Loop: Win the War Against aria-hidden
The instinct here is to use a MutationObserver to strip inert whenever it's applied. That won't work. The aria-hidden package has its own observer that immediately re-applies it. Your observer and theirs enter a race condition — and yours will lose frames.
The solution is requestAnimationFrame. It fires before every paint, meaning the dropdown is always clean and interactive by the time the user sees it — regardless of what aria-hidden's observer does between frames.
useEffect(() => {
if (!open) return;
let rafId: number;
const unblock = () => {
document.querySelectorAll(".pac-container").forEach((el) => {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
});
rafId = requestAnimationFrame(unblock);
};
rafId = requestAnimationFrame(unblock);
return () => cancelAnimationFrame(rafId);
}, [open]);
"Isn't a RAF loop expensive?"
No. querySelectorAll on a class selector is near-instant. Removing a non-existent attribute is a no-op. The loop only runs while the dialog is open and cleans up automatically on close. In practice, the performance footprint is negligible.
Part 3 — onInteractOutside: Stop Radix From Closing the Dialog on Selection
const handleInteractOutside = useCallback((e: Event) => {
const target = e.target as Element | null;
if (target?.closest(".pac-container")) {
e.preventDefault();
}
}, []);
Wire it to your DialogContent:
<DialogContent onInteractOutside={handleInteractOutside}>
{/* Google Places Autocomplete input */}
</DialogContent>
Clicking a suggestion is no longer treated as an outside interaction. The dialog holds open. The selection fires correctly. Done.
Full Copy-Paste Solution
Step 1 — Add to your global CSS:
.pac-container {
z-index: 9999 !important;
pointer-events: auto !important;
}
Step 2 — Drop this into your dialog component:
"use client";
import { useCallback, useEffect, useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@radix-ui/react-dialog";
export function AddressFormDialog() {
const [open, setOpen] = useState(false);
// Part 2: Strip inert/aria-hidden every frame while the dialog is open
useEffect(() => {
if (!open) return;
let rafId: number;
const unblock = () => {
document.querySelectorAll(".pac-container").forEach((el) => {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
});
rafId = requestAnimationFrame(unblock);
};
rafId = requestAnimationFrame(unblock);
return () => cancelAnimationFrame(rafId);
}, [open]);
// Part 3: Prevent dialog dismissal when clicking a Places suggestion
const handleInteractOutside = useCallback((e: Event) => {
const target = e.target as Element | null;
if (target?.closest(".pac-container")) {
e.preventDefault();
}
}, []);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>Edit Address</DialogTrigger>
<DialogContent onInteractOutside={handleInteractOutside}>
{/* Your Google Places Autocomplete input here */}
</DialogContent>
</Dialog>
);
}
Why Every Other "Fix" Falls Short
| Common suggestion | Why it fails |
|---|---|
Just add z-index to .pac-container
|
Doesn't touch inert or pointer-events — dropdown is still frozen |
Use modal={false} on the Dialog |
Disables focus trapping and dismissal — breaks accessibility entirely |
| Portal the Autocomplete manually | Google Places manages its own DOM insertion — you can't control it |
MutationObserver to remove inert
|
Races with aria-hidden's own observer and consistently loses frames |
Only handle onPointerDownOutside
|
Misses keyboard selection; doesn't address inert at all |
Compatibility
This fix works with all of the following — no modifications needed:
-
@radix-ui/react-dialog(any version) @react-google-maps/api@googlemaps/react-wrapper- Raw
google.maps.places.Autocomplete - Radix
AlertDialog,Sheet,Drawer, and any component built onDismissableLayer
The Bigger Lesson
This bug exists because two well-engineered libraries make opposing assumptions about the DOM:
-
Radix assumes it owns
<body>when a modal is open and aggressively enforces that contract. -
Google Places assumes it can freely append to
<body>and have those elements be interactive.
Neither library is wrong on its own terms. But when they meet, the result is a silent failure that's nearly impossible to debug without understanding both systems deeply.
The three-part fix isn't a hack — it's a precise surgical response to each layer of the conflict. CSS restores visibility. RAF wins the frame race. onInteractOutside protects the selection. Remove any one part and the bug comes back.
Top comments (0)