# Radix Dialog Leaves pointer-events: none on the Body? Here's the Fix

> 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.

- URL: https://qodesign.dev/blog/radix-dialog-pointer-events-none-body-fix
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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.

## Check if pointer-events is the problem

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.

## Why Radix sets pointer-events: none on the body

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.

## The setup that works

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](/react/delete-with-undo-server-action), the server action waits until a five-second undo window closes, and there's no dialog left to lock the page.

## How two copies of DismissableLayer lock the page

To get two copies, I installed an older @radix-ui/react-dialog (1.1.2) under an alias and changed one import in the same component:

```tsx
import { DropdownMenu } from "radix-ui";
import * as Dialog from "dialog-old"; // an older @radix-ui/react-dialog
```

`npm ls` showed the split:

```txt
$ npm ls @radix-ui/react-dismissable-layer
├─┬ @radix-ui/react-dialog@1.3.1
│ └── @radix-ui/react-dismissable-layer@1.2.1
├─┬ @radix-ui/react-dropdown-menu@2.2.1
│ └─┬ @radix-ui/react-menu@2.2.1
│   └── @radix-ui/react-dismissable-layer@1.2.1 deduped
├─┬ dialog-old@npm:@radix-ui/react-dialog@1.1.2
│ └── @radix-ui/react-dismissable-layer@1.1.1
└─┬ radix-ui@1.8.1
  ├── @radix-ui/react-dismissable-layer@1.2.1 deduped
  ...
```

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.

## Fix 1: deduplicate the package

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`:

```json
{
  "overrides": {
    "@radix-ui/react-dismissable-layer": "1.2.1"
  }
}
```

After `npm install`, the old dialog's line read:

```txt
├─┬ dialog-old@npm:@radix-ui/react-dialog@1.1.2
│ └── @radix-ui/react-dismissable-layer@1.2.1 deduped
```

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.

## Fix 2: make the menu non-modal

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.

## The cleanup workaround from the GitHub issues

The Radix issue threads about this bug are full of cleanup workarounds. The usual shape clears the style when the dialog closes:

```tsx
<Dialog.Content
  onCloseAutoFocus={() => {
    document.body.style.pointerEvents = "";
  }}
>
```

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.

## What didn't cause it

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.

## A drawer that doesn't touch the body at all

qodesign's [Cart Drawer](/components/cart-drawer) doesn't use Radix. It puts the `inert` attribute on the page wrapper while the drawer is open and takes it off when the drawer closes, so one component owns the state and there's nothing on `body` to forget. There's a [Next.js and Stripe version](/react/stripe-checkout-cart-drawer) and an [animated cart drawer guide](/react/animated-cart-drawer). If your drawer also fights the page for scroll, [this post covers scroll containers stealing the page scroll](/blog/scroll-container-stealing-page-scroll-react). Free pieces with full source are at [/free](/free).

## FAQ

### Why does my page stay unclickable after closing a shadcn dialog?

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.

### How do I remove pointer-events: none from the body?

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.

### Should I open a Radix Dialog from a DropdownMenu item?

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.

### Does modal false on DropdownMenu fix the frozen page?

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.
