#657·vaul

iOS scroll lock cancels all touchmove for scrollers inside a shadow root

Author: pranshugupta54Created Aug 27, 2026Updated Aug 27, 2026

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:

javascript
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

  1. Render a Drawer.Root (modal, default repositionInputs) on iOS or an iOS simulator.
  2. Inside Drawer.Content, mount a custom element whose shadow root contains the scrollable region (overflow-y: auto), with content taller than the viewport.
  3. 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:

javascript
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