DEV Community

graciesharma
graciesharma

Posted on

Google Places Autocomplete Is Completely Broken Inside Radix Modals — Here's the 3-Part Fix

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, a requestAnimationFrame loop, and an onInteractOutside guard. 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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

"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();
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

Wire it to your DialogContent:

<DialogContent onInteractOutside={handleInteractOutside}>
  {/* Google Places Autocomplete input */}
</DialogContent>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 on DismissableLayer

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)