iOS scroll lock cancels all touchmove for scrollers inside a shadow root
Summary
preventScrollMobileSafari() resolves the touched element with e.target, which retargets to the shadow host for a touch that begins inside a shadow root. getScrollParent then climbs parentElement, which does not cross the shadow boundary — so a scroll container that lives inside a web component is invisible to the walk.
When the walk finds nothing scrollable it returns document.documentElement, and onTouchMove takes its first branch:
let onTouchMove = (e) => {
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
e.preventDefault(); // cancels the scroll in every direction
return;
}
...
};Because that listener is registered on document with { passive: false, capture: true }, the content inside the web component becomes completely unscrollable — not merely rubber-band-limited at the edges, but inert in all directions.
Reproduction
- Render a
Drawer.Root(modal, defaultrepositionInputs) on iOS or an iOS simulator. - Inside
Drawer.Content, mount a custom element whose shadow root contains the scrollable region (overflow-y: auto), with content taller than the viewport. - Try to scroll that region with a finger.
Observed: nothing scrolls, in any direction. A sibling scroller in ordinary light DOM inside the same drawer scrolls normally, which is the tell.
Verified on a real device rather than inferred: the only overflow-y: auto in the ancestor chain was inside the shadow root, and every light-DOM ancestor above the host reported hidden or visible — exactly the condition for the walk to fall through to documentElement.
Why the existing escape hatches don't cover it
data-vaul-no-drag is consumed by shouldDrag, which is the drag/dismiss decision — a different code path. The scroll lock has no opt-out of its own, so the only lever is repositionInputs={false}, which also disables the iOS input repositioning the same function provides. That is a real cost for any drawer containing a text field.
Suggested fix
Resolve the touch target through the composed path so the walk starts at the true innermost target:
let onTouchStart = (e) => {
const target = e.composedPath?.()[0] ?? e.target;
scrollable = getScrollParent(target);
...
};composedPath()[0] is the retarget-free element and falls back cleanly on browsers without it. getScrollParent may also want to hop getRootNode().host when it reaches a shadow boundary, so a scroller outside the component is still found from within it.
Happy to open a PR if the approach looks right.
Environment
- vaul 1.1.2
- iOS / WKWebView
- Scroll container inside a custom element's shadow root
Source: emilkowalski/vaul