#5720·base-ui

[dialog] Scroll lock is skipped when the page scroller is <body> under an overflow:hidden <html>

Author: jjv3213Created Sep 14, 2026Updated Sep 15, 2026
Labelsstatus: waiting for maintainercomponent: dialog

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:

css
html { height: 100dvh; overflow: hidden; }
body { height: 100%; overflow-y: auto; }

useScrollLock reads <html>, sees overflow: hidden, and treats the page as already locked:

javascript
// 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:

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

xml
<style>
  html { height: 100dvh; overflow: hidden; }
  body { height: 100%; overflow-y: auto; margin: 0; }
</style>
javascript
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:

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

  1. Detection. Before concluding the page is locked, it may be worth checking whether the other element is itself a scroll container — e.g. <html> is hidden/clip but <body> has overflow-y: auto|scroll and scrollHeight > clientHeight. In that case <body> is the page scroller and is what needs locking.

  2. Application. Inline styles lose to author !important. In the case above, body { overflow: hidden auto !important } cannot be overridden by body.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's ModalManager has the same limitation for the same reason.)