移动端 vaul 抽屉无法下拉关闭?根挂 overflow 拦截拖拽手势
在手机上打开筛选抽屉时,内容看得到但滚不动,下拉也关不掉——桌面浏览器里一切正常,只有真机出问题。
在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,筛选和分类管理都走底部抽屉交互。这个坑我们修了两次:第一次修复后,另一次页面重写凭记忆重排了抽屉 JSX,同一个坑几小时内就回来了。
TL;DR
- 根因:
DrawerContent的根元素是 vaul 的拖拽层(负责下拉关闭手势)。根上挂overflow-y-auto(连同max-h-[80vh]这类限高重排)后,浏览器把触摸手势判定为「滚动容器滚动」,vaul 的拖拽监听收不到事件——抽屉既不可滚(视觉上被 max-h 截断)也无法下拉关闭。 - 解法:滚动下沉内层——根上只留固定区(标题栏),内容包进
flex-1 min-h-0 overflow-y-auto的内层容器。 - 元教训:修过的结构性坑,重写页面时必须从模板出发核对,不能凭旧代码记忆重排 JSX。
现象:桌面正常,真机抽屉「钉死」
抽屉用的是 shadcn/ui 的 Drawer 组件(底层是 vaul ^1.1.2)。故障表现:桌面浏览器里抽屉能滚能关,一切正常;真机上内容区滚不动、下拉关闭也无效,抽屉像钉死了一样。而且「滚不动」和「关不掉」总是同时出现。
这个组合症状指向的不是样式问题,而是手势问题——滚动和拖拽在移动端是同一根手指的同一段触摸动作,归浏览器还是归组件,要看手势落在哪个元素上。
根因:滚动容器和拖拽层抢同一个手势
vaul 的下拉关闭依赖根元素上的触摸手势监听:手指按住任意位置往下拖,根元素持续收到 touchmove,拖动距离超过阈值就关闭抽屉。
当根元素同时是滚动容器时,移动端浏览器的手势判定会先介入:手指移动被解释为「滚动这个容器」,浏览器消费掉这组触摸事件,vaul 的拖拽监听收不到足够的 touchmove——拖拽判定永远不成立。这就是「滚不动 + 关不掉」成对出现的原因:滚动容器赢了手势,vaul 输了,而内容又因 max-h 被截断无法完整展示。
出问题的结构(回归版本,抽自真实 diff):
{/* 错误:根是拖拽层,却挂了滚动 + 限高 */}
<DrawerContent className="max-h-[80vh] overflow-y-auto px-4 pb-6 text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-4">{/* 内容 */}</div>
<DrawerFooter>{/* 操作按钮 */}</DrawerFooter>
</DrawerContent>
这个写法在桌面浏览器完全正常——鼠标滚轮直接驱动滚动容器,vaul 的下拉关闭靠把手(handle)仍可用。所以问题极易在开发阶段漏掉,直到真机走查才暴露。
修复:滚动下沉内层,根只留固定区
正确结构是把滚动职责从根上摘掉,交给内层容器;标题栏留在根上固定:
{/* 正确:根无 overflow,滚动下沉内层 */}
<DrawerContent className="text-left">
<DrawerHeader>
<DrawerTitle>筛选</DrawerTitle>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-6">
{/* 内容 + Footer 都在这里滚 */}
</div>
</DrawerContent>
三个关键点:
- 根上零 overflow、零 max-h——拖拽手势畅通,任意位置下拉都能关闭。
- 内层
flex-1 min-h-0——flex 子项默认min-height:auto,不压到零的话内容会把抽屉撑开、内层根本不产生滚动条;min-h-0是滚动真正生效的前提。 - 标题栏留在根上——视觉上标题固定、内容滚动,交互上把手和标题区的下拉是「关闭」,内容区的滑动是「滚动」,两个手势各归其主。
修复后用 Playwright 手势验证了三件事:根元素 overflowY === 'visible'、内层 scrollTop 可独立滚动、模拟拖拽下拉能关闭抽屉。三条全过,桌面回归无影响。
为什么会回归:凭记忆重排 JSX
更值得写下来的是这个坑的回归过程。第一次修复(commit a182098,2026-09-23 08:55)解决了「移动端不可滚」;之后一次页面重写,写的时候没有对照修复记录,凭对旧代码的记忆重排了抽屉 JSX——max-h 和 overflow-y-auto 又回到了根上。同日 12:10,第二次修复(f1097b2)落地,和第一次相隔不到 4 小时。
结构性的坑(DOM 层级、手势归属、滚动容器位置)和普通逻辑 bug 不同:它不是「记住结论」就能防住的,因为重写代码的人未必是踩过坑的人,记忆也未必是自己的。可靠的做法是把正确结构沉淀成可复制的模板,规则一句话——「DrawerContent 根是 vaul 拖拽层,禁挂 overflow/max-h,滚动下沉内层」——重写时照模板复制,写完跑一次手势断言。
注意事项
这套「滚动下沉内层」不只适用于 vaul:任何「根元素承载拖拽手势」的组件(各类 bottom sheet、可拖拽 Dialog)都遵循同一规律——手势层和滚动层必须分层。另外 min-h-0 在 flex 列布局里是滚动生效的隐性前提,漏掉它时症状是「内层不出现滚动条、抽屉被撑高」,和根挂 overflow 的症状不同,排查时先确认滚动容器本身能不能滚,再往前追手势归属。
常见问题
移动端底部抽屉无法下拉关闭怎么办?
先检查 DrawerContent 根元素是否挂了 overflow-y-auto 或 max-h——DrawerContent 的根是 vaul 的拖拽层,根上的滚动容器会把触摸手势判定为滚动,vaul 收不到拖拽事件。把滚动下沉到内层容器(flex-1 min-h-0 overflow-y-auto),标题栏留在根上固定。
底部弹窗能打开但内容滚不动,是怎么回事?
「滚不动」和「拉不下来关闭」往往是同一个根因的两个症状:滚动容器放在了组件根上,触摸移动被滚动消费,vaul 的拖拽手势被拦截。滚动下沉内层后两个症状同时消失——内容区滑动是滚动,把手或标题区下拉是关闭。
vaul 的 DrawerContent 上为什么不能放 overflow-y-auto?
DrawerContent 根元素绑定着 vaul 的拖拽手势监听,负责任意位置下拉关闭;根上放 overflow-y-auto 后,浏览器优先把手势判定为容器滚动,touchmove 事件到不了 vaul 的监听层。这不是 vaul 的 bug,是滚动容器和手势层抢同一个手势的归属。