Interface Lab
返回知识库
Interaction Responsive Motion

浮层焦点与滚动锁契约

浮层不是一层遮罩,而是 modality、初始焦点、焦点循环、关闭规则、背景 inert、滚动锁、portal 层级和移动端安全区的联合契约。

设计原则

只要浮层改变用户可交互范围,就必须同时治理键盘、读屏、指针、滚动和移动端行为;视觉上盖住背景但语义上仍可达,是最危险的假 modal。

大理论

浮层设计的核心不是 z-index,而是交互上下文切换。modal dialog、drawer、sheet、command palette、confirmation、popover、menu 和 tooltip 看起来都像“浮在页面上”,但它们的 modality 不同:modal 要让背景不可交互,焦点被限制在浮层内;非 modal popover 只提供临时上下文,焦点和背景关系更轻;tooltip 不应承载任务必需内容。实现前先决定这个浮层是否阻断主页面,再决定 focus、dismissal、scroll、portal 和移动端策略。

小知识点

  1. 模式分类先于组件选择:modal dialog/drawer/sheet 需要背景 inert、焦点限制和关闭后返回触发点;non-modal popover/menu 需要触发器关系、Escape/外部点击和键盘路径;tooltip 不可交互。
  2. 初始焦点不是永远第一个按钮:短确认可聚焦安全的取消/关闭,长内容可聚焦标题或顶部静态节点,命令面板可聚焦搜索输入,破坏性确认不要自动聚焦危险按钮。
  3. modal 里的 Tab 和 Shift+Tab 应在浮层内循环;关闭后焦点回到 trigger,如果 trigger 消失,回到逻辑替代入口,例如列表行、页面标题或创建按钮。
  4. aria-modal="true" 只能用于真正 modal 的浮层:代码必须阻止所有用户与背景交互,视觉也要让背景明显不可用。否则读屏用户会被错误隐藏上下文。
  5. <dialog>.showModal() 可以让文档其余节点进入浏览器的 top-layer/inert 机制;自定义 portal/Radix/headless 实现仍要显式处理 inert、aria、focus、Escape 和 restore。
  6. 滚动锁要区分 body、app 主滚动容器和浮层内部滚动;锁背景时保留滚动位置和滚动条补偿,让长浮层内部可滚,防止 iOS/移动端滚动穿透和 scroll chaining。
  7. 关闭规则要有状态矩阵:close button、Escape、outside click、route change、browser back、swipe down 和 dirty form 各自是否允许,是否需要确认,关闭后焦点去哪。
  8. 堆叠规则必须明确:nested dialog、popover inside dialog、toast、sticky header、portal root、z-index token 和 top-layer 行为要可预测;不要让多个 bottom sheet 形成不可理解的栈。
  9. 动画不能让不可见控件继续可聚焦;exit 动画期间需要 inert/unmount 时机和 focus return 同步,prefers-reduced-motion 下减少位移。
  10. 移动端 sheet/drawer 要检查 safe-area、动态 viewport、虚拟键盘、底部固定行动区、可见关闭按钮和 Back 手势;只靠拖拽把手关闭不够可访问。

设计判断

如果用户能只用键盘打开、理解、操作、关闭浮层,焦点不会逃入背景,读屏不会进入被遮住内容,body 不会在浮层后面滚动,移动端键盘和安全区不遮挡关键控件,这个浮层才算成立。反过来,任何“看起来像 modal 但背景仍可 Tab/读屏/滚动”的实现都应按高风险 bug 处理。

实现建议

输出 OverlayContract:type、modality、triggerRef、initialFocusTarget、focusTrapScope、returnFocusTarget、closeMethods、dirtyClosePolicy、ariaRole、ariaLabelledBy、ariaDescribedBy、inertTargets、portalRoot、zIndexToken、scrollLockTarget、scrollPositionRestore、internalScrollArea、overscrollBehavior、iosTouchPolicy、safeAreaInsets、keyboardViewportPolicy、animationMountPolicy、nestedOverlayPolicy、testAssertions。React 中优先用 Radix Dialog/Popover、React Aria useModalOverlay 或本地已验证 primitive;自写时用 Playwright 覆盖 Tab/Shift+Tab/Escape/close/return focus/body scroll/mobile viewport。

反例/风险

div 视觉弹窗没有 role=dialog<dialog>;只加遮罩但背景按钮仍可 Tab;关闭后 document.activeElement 变成 body;aria-modal=true 但背景还能点击;长弹窗内容不可滚;body 滚动跳到顶部;iOS bottom sheet 触摸穿透到底层列表;Escape 关闭了外层而不是内层;命令面板 exit 动画期间隐藏输入仍可聚焦;popover 里放多步表单和危险提交。

案例分析

WAI-ARIA APG 的 modal dialog 模式把背景 inert、焦点限制、Tab 循环和初始焦点选择视为同一套行为,并警告只有真正阻止背景交互时才设置 aria-modal。HTML/MDN 的 <dialog> 与 WHATWG inert 机制说明 showModal() 会把其余文档放进 top layer/inert 语义;React Aria useModalOverlay 把辅助技术隐藏、focus containment/restore 和页面滚动锁作为 overlay hook 的核心职责;Radix Dialog/Popover 则把 modal/non-modal、focus、Title/Description、Escape 和 trigger return 封装成 primitive;NN/g 的 bottom sheet 研究补充了移动端:modal sheet 阻断背景、nonmodal sheet 保留背景交互,但都需要明确关闭入口,避免堆叠和长任务。可学习点是:浮层组件的可用性来自行为契约,而不是阴影、圆角或遮罩颜色。

来源链接

WAI-ARIA APG Modal Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ MDN dialog element: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog WHATWG HTML inert/dialog top layer: https://html.spec.whatwg.org/dev/interaction.html React Aria useModalOverlay: https://react-aria.adobe.com/Modal/useModalOverlay.html Radix Dialog: https://www.radix-ui.com/primitives/docs/components/dialog Radix Popover: https://www.radix-ui.com/primitives/docs/components/popover Primer Dialog Accessibility: https://primer.style/product/components/dialog/accessibility/ MDN overscroll-behavior: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overscroll-behavior NN/g Bottom Sheets: https://www.nngroup.com/articles/bottom-sheet/

Agent 指令

实现浮层前必须输出 modality、focus map、close matrix、background inert、scroll-lock target、portal/z-index、mobile safe-area 和测试断言;视觉遮罩不能替代语义与滚动锁。