#656·vaul

Body scroll lock is never released when a drawer closes (only when its Root unmounts)

Author: Vallo33Created Aug 27, 2026Updated Aug 27, 2026

What happens

After a drawer closes, the body stays scroll-locked permanently. body keeps data-scroll-locked and the react-remove-scroll stylesheet stays in <head>, so body { overflow: hidden } still applies and the page cannot be scrolled until a reload.

It is not a delay (unlike the old #64) — it never releases.

Versions

  • vaul 1.1.2 (latest)
  • react / react-dom 19.2.8
  • @radix-ui/react-dialog 1.1.4 (as bundled by vaul)

What I measured

The lock is react-remove-scroll's, mounted by the Radix modal Dialog that vaul builds on — Root renders DialogPrimitive.Root with no modal prop, so it defaults to modal. react-remove-scroll-bar refcounts through the data-scroll-locked attribute itself.

  1. The drawer's subtree really does unmount. I put a probe child inside Drawer.Content with a useEffect cleanup — the cleanup runs, and every [data-vaul-drawer] node leaves the DOM. But useLockAttribute's cleanup never runs, so the refcount stays at 1 and the stylesheet stays mounted.
  2. Unmounting Drawer.Root does release it — attribute removed, stylesheet gone, overflow restored. So the release path works; it just isn't reached on close.
  3. A plain Radix Dialog does not leak, on the same React version in the same environment. That's what points at vaul rather than at Radix or react-remove-scroll.

Nothing needs to be rendered inside the drawer — an empty one reproduces it.

Minimal reproduction

javascript
import { Drawer } from 'vaul';

function Repro({ open }) {
  return (
    <Drawer.Root open={open}>
      <Drawer.Portal>
        <Drawer.Overlay />
        <Drawer.Content>
          <Drawer.Title>Sheet</Drawer.Title>
          <Drawer.Description>A sheet.</Drawer.Description>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

Render with open, then re-render with open={false} and let the exit animation finish. Then:

javascript
document.body.getAttribute('data-scroll-locked')      // "1"  — expected null
getComputedStyle(document.body).overflow              // "hidden" — expected ""
document.querySelectorAll('[data-vaul-drawer]').length // 0

Re-rendering the same tree as <div /> instead (unmounting Root) gives null / "" as expected.

Caveat on environment

The measurements above are from jsdom (vitest + Testing Library), where I dispatch the animationend that Radix's Presence waits on, since jsdom never fires it. The user-visible symptom that led me here was on a real iOS device — a page that could not be scrolled after closing a bottom drawer until it was reloaded — and the workaround below fixed it there, so I don't think the manual dispatch is what creates it. Happy to put together a browser-based repro if that would help.

Workaround, for anyone else hitting this

Keep the drawer mounted for its exit animation, then take it out of the tree:

javascript
function useDrawerPresence(isOpen) {
  const [isMounted, setIsMounted] = React.useState(isOpen);
  React.useEffect(() => {
    if (isOpen) { setIsMounted(true); return; }
    if (!isMounted) return;
    const t = setTimeout(() => setIsMounted(false), 500); // TRANSITIONS.DURATION
    return () => clearTimeout(t);
  }, [isOpen, isMounted]);
  return isOpen || isMounted;
}

Thanks for vaul — the drag behaviour is lovely, and this is the only rough edge we've hit.