[dialog] Scroll lock is skipped when the page scroller is <body> under an overflow:hidden <html>
Current behavior
When a third party moves the page scroll container onto <body> by locking <html>, Base UI never applies a scroll lock, and the page stays scrollable behind an open modal Dialog.
The setup that triggers it:
html { height: 100dvh; overflow: hidden; }
body { height: 100%; overflow-y: auto; }useScrollLock reads <html>, sees overflow: hidden, and treats the page as already locked:
// If the site author already hid overflow on <html>, respect it and bail out.
if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') { this.restore = NOOP; return; }With an open Dialog, <html> and <body> carry no inline styles, data-base-ui-scroll-locked is absent, and getComputedStyle(document.body).overflowY is still auto — the page scrollbar remains visible and can be dragged with the mouse.
This is not the same as #4640 / #4665. That covers a transient lock from another overlay that later clears. Here nothing ever clears: <html> is legitimately and permanently locked, and <body> is an independent scroll container.
I also ported the current @base-ui/[email protected] logic verbatim and ran it against the same page — it still does not lock:
function getViewportScroller(html, body) {
return isOverflowElement(html) ? html : body;
}isOverflowElement(html) is true here, so it returns <html>, reads hidden, and concludes the page is locked. The #4665 MutationObserver handoff then waits for a lock that never clears, so the dialog never takes over and an observer is left attached on every open.
Expected behavior
<body> is the element that actually scrolls, so it should be locked while a modal Dialog is open.
Reproducible example
Any modal Dialog on a page with the stylesheet below. Verified against a minimal app:
<style>
html { height: 100dvh; overflow: hidden; }
body { height: 100%; overflow-y: auto; margin: 0; }
</style>function App() {
const [open, setOpen] = React.useState(false);
return (
<>
<button onClick={() => setOpen(true)}>open</button>
{Array.from({ length: 60 }, (_, i) => <p key={i}>Page paragraph {i}</p>)}
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Backdrop style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.5)' }} />
<Dialog.Popup style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}>
<Dialog.Title>Title</Dialog.Title>
<button onClick={() => setOpen(false)}>close</button>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</>
);
}Open the dialog, then drag the page scrollbar with the mouse — the page behind scrolls. Inspect <html>/<body>: no inline styles were written.
Base UI version
@base-ui/[email protected] (@base-ui/[email protected]). Detection logic from 1.8.0 / @base-ui/[email protected] verified to behave the same way.
Which browser are you using?
Chrome
Which OS are you using?
Linux (also reproduces on the same markup in Chrome on other platforms)
Additional context
This is not a contrived setup. We hit it on a production marketing site: the Qualified chat widget's "docking" feature (js.qualified.com/docking.js) injects, on every page at load and regardless of whether the panel is ever docked:
html { height: 100dvh !important; overflow: hidden !important; contain: inline-size layout !important; }
body { overflow: hidden auto !important; height: 100% !important; width: calc(100% + 15px) !important; }It does this so it can shrink the page for a docked side panel without layout shift. The same "<html> as a fixed frame, <body> as the scroller" pattern is common in app shells generally.
Two things that might be worth considering:
Detection. Before concluding the page is locked, it may be worth checking whether the other element is itself a scroll container — e.g.
<html>ishidden/clipbut<body>hasoverflow-y: auto|scrollandscrollHeight > clientHeight. In that case<body>is the page scroller and is what needs locking.Application. Inline styles lose to author
!important. In the case above,body { overflow: hidden auto !important }cannot be overridden bybody.style.overflow = 'hidden', so even correct detection would not be enough.setProperty('overflow', 'hidden', 'important')or an injected stylesheet rule would be robust against this. (MUI'sModalManagerhas the same limitation for the same reason.)
Source: mui/base-ui