TL;DR Radix sets pointer-events: none on the body while a modal is open. If it stays after closing, check npm ls @radix-ui/react-dismissable-layer for two versions. Two copies restore the wrong value. Dedupe with radix-ui or an npm override, or set modal={false} on the dropdown that opens the dialog.
If your page stops responding to clicks after you close a shadcn Dialog, Sheet or AlertDialog, open DevTools and look at the body tag. You'll find style="pointer-events: none;" left behind. Radix sets that while a modal is open and removes it on close. When it stays, the cause I could reproduce on current versions is two copies of Radix's DismissableLayer package in node_modules, usually from mixing the radix-ui package with older @radix-ui/react-* installs. Each copy keeps its own count of open layers, they restore the wrong value, and the page stays dead. Deduplicating the package fixes it at the source.
I tested this on a fresh Next.js 16.4.2 app with React 19.3.1, radix-ui 1.8.1 and @radix-ui/react-dialog 1.3.1, built with next build, served with next start and driven in headless Chromium. After each dialog closed, the test read the body's style and clicked a button on the page with the real mouse, not a scripted .click(), to see if the click landed.
Paste this into the console while the page is stuck:
js
getComputedStyle(document.body).pointerEvents
If it prints "none" with no dialog on screen, this post is for you. To unstick the page for now, run document.body.style.pointerEvents = "". That only clears the symptom, so keep reading.
Stack Cart Drawer running live. Products fly into the cart as discs; open it and they unfold into line items.
A modal Radix component, like Dialog, AlertDialog or DropdownMenu (which is modal by default), blocks clicks outside itself. It does that by setting pointer-events: none on body and pointer-events: auto on its own content. The code that does it lives in @radix-ui/react-dismissable-layer. When the first blocking layer opens, it saves the body's current value and sets none. When the last one closes, it puts the saved value back.
That "save, then restore" happens at module level, and the count of open layers lives in that module's React context. Both work as long as every Radix component in your app shares one copy of the module.
This is a row menu with a Delete item that opens a confirm dialog, which is the pattern behind most "page frozen after closing dialog" reports:
tsx
// components/RowActions.tsx"use client";import { useState } from "react";import { Dialog, DropdownMenu } from "radix-ui";export function RowActions({ onDelete }: { onDelete: () => void }) { const [confirmOpen, setConfirmOpen] = useState(false); return ( <> <DropdownMenu.Root> <DropdownMenu.Trigger className="rounded-md border border-zinc-700 px-3 py-1.5"> Actions </DropdownMenu.Trigger> <DropdownMenu.Portal> <DropdownMenu.Content className="rounded-md bg-zinc-800 p-1" sideOffset={6}> <DropdownMenu.Item className="rounded px-3 py-1.5 outline-none data-[highlighted]:bg-zinc-700" onSelect={() => setConfirmOpen(true)} > Delete </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Portal> </DropdownMenu.Root> {/* The dialog is a sibling of the menu, not a child of the menu item. */} <Dialog.Root open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/60" /> <Dialog.Content className="fixed left-1/2 top-1/2 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-zinc-900 p-6"> <Dialog.Title className="text-lg font-semibold">Delete this row?</Dialog.Title> <Dialog.Description className="mt-1 text-sm text-zinc-400"> This can't be undone. </Dialog.Description> <div className="mt-5 flex justify-end gap-2"> <Dialog.Close className="rounded-md px-3 py-1.5">Cancel</Dialog.Close> <button className="rounded-md bg-red-600 px-3 py-1.5" onClick={() => { onDelete(); setConfirmOpen(false); }} > Delete </button> </div> </Dialog.Content> </Dialog.Portal> </Dialog.Root> </> );}
With one copy of DismissableLayer in the tree, I closed it nine times across three test runs, with Cancel, with Escape and with the Delete button. Every time the body read pointer-events: none while the dialog was open, the inline style was empty after it closed, and the click on the page landed.
So the classic advice, "open the dialog from a menu item and the page locks", didn't reproduce on current versions when everything comes from the same package. The dialog sitting next to the menu, controlled by state, with onSelect setting it open, worked every time.
If the confirm dialog is only there to stop accidental deletes, you can drop it. With delete with undo, the server action waits until a five-second undo window closes, and there's no dialog left to lock the page.
Same clicks, same component. All three close methods left the page locked: after the dialog closed, document.body.style.pointerEvents was "none" and the real mouse click on the page did nothing.
The order of events explains it. The menu (copy A) opens and saves the body's value, an empty string, then sets none. You pick Delete. The dialog (copy B) opens while the menu is still closing. Copy B has its own empty layer count, so it thinks it's first and saves the body's current value, which by now is copy A's none. The menu finishes closing and copy A restores the empty string. The dialog closes and copy B restores what it saved: none.
There was a second sign in the test that something was off: while the dialog was open, the body's inline style was already empty. The two copies disagreed the whole time. The overlay covered the page, so it didn't show until the dialog closed.
How you end up with two copies: older shadcn/ui projects install one @radix-ui/react-* package per component, newer ones import everything from radix-ui, and a component pasted in from v0, Lovable or another registry can bring its own pinned version. If the versions don't line up, npm installs a second copy next to the first.
Run npm ls @radix-ui/react-dismissable-layer. If you see more than one version, make them one. The cleanest way is to move every import to the radix-ui package so there's only one set of versions. If a dependency pins its own, force a single version with npm's overrides in package.json:
I rebuilt and ran the same three closes with the old dialog still in place. The page unlocked after every one, and the body showed none while the dialog was open, which is what you want.
pnpm has pnpm.overrides and Yarn has resolutions for the same job. I only tested npm.
If you can't dedupe right away, modal={false} on the menu's root stops it from blocking the page in the first place, so it never saves or restores anything:
tsx
<DropdownMenu.Root modal={false}>
With the two copies still installed, that one prop made all three close methods leave the page clickable. The menu no longer blocks clicks outside itself while open, which for a small row menu is usually fine.
In my test with the two copies, this did unlock the page after all three close methods. It also leaves both copies installed and still disagreeing, so the next pair of overlays that overlap can trip over the same thing. I'd use it as a stopgap while you fix the versions.
I also removed a Dialog from the tree while it was open, 300 ms after opening, which is the same thing that happens when a parent stops rendering it. Radix's cleanup ran on unmount and the body went back to normal. If you see the lock after a navigation, check for two copies before blaming the unmount.
Radix left pointer-events: none on the body. In my tests that happened when the app had two copies of @radix-ui/react-dismissable-layer and a menu opened the dialog. Check with npm ls @radix-ui/react-dismissable-layer and dedupe to one version.
To unstick the page right now, run document.body.style.pointerEvents = "" in the console. For a real fix, make sure only one copy of Radix's DismissableLayer is installed, or set modal={false} on the dropdown that opens the dialog.
Yes. Render the Dialog next to the DropdownMenu, keep its open state in useState, and set it to true in the menu item's onSelect. With one copy of Radix installed, that worked on every close method I tried.
In my test it did, even with two copies of Radix installed, because a non-modal menu never changes the body's pointer events. The menu then lets clicks outside it through while it's open.