Body scroll lock is never released when a drawer closes (only when its Root unmounts)
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-dialog1.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.
- The drawer's subtree really does unmount. I put a probe child inside
Drawer.Contentwith auseEffectcleanup — the cleanup runs, and every[data-vaul-drawer]node leaves the DOM. ButuseLockAttribute's cleanup never runs, so the refcount stays at 1 and the stylesheet stays mounted. - Unmounting
Drawer.Rootdoes release it — attribute removed, stylesheet gone,overflowrestored. So the release path works; it just isn't reached on close. - A plain Radix
Dialogdoes not leak, on the same React version in the same environment. That's what points at vaul rather than at Radix orreact-remove-scroll.
Nothing needs to be rendered inside the drawer — an empty one reproduces it.
Minimal reproduction
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:
document.body.getAttribute('data-scroll-locked') // "1" — expected null
getComputedStyle(document.body).overflow // "hidden" — expected ""
document.querySelectorAll('[data-vaul-drawer]').length // 0Re-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:
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.
Source: emilkowalski/vaul