移动端导航遮罩层

遮罩层Overlay

浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)

反馈 规格 40 · 遮罩层 Overlay 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">

<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-overlay.css">

<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->

演示

每个演示都是真实渲染:预览帧加载 frameworks-mobile/Overlay.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。

01 组件类型

基础遮罩tone=default

tone=default:遮罩本身不带语义,role 与标题由内容自己给;一次轻点遮罩即关闭。

查看代码(演示页原文 · 20 行)
frameworks-mobile/Overlay.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础遮罩(tone=default:点遮罩关闭;遮罩是纯装饰,内容语义由槽位承担)</p>
  <div class="demo-frame" data-assert="overlay-basic">
    <div class="demo-frame__body">
      <button class="demo-trigger" type="button" id="overlay-basic-trigger"
              data-behavior="click-toggles-class:#overlay-basic|is-open">打开遮罩</button>
    </div>
    <div class="kole-m-overlay kole-m-overlay--default" id="overlay-basic" role="presentation"
         data-open="false">
      <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
      <div class="kole-m-overlay__content">
        <div class="demo-card">
          <p class="demo-card__title">遮罩上的内容</p>
          <p class="demo-card__text">遮罩本身不带语义,role 与标题由内容自己给。</p>
          <button class="demo-close" type="button" id="overlay-basic-close">关闭</button>
        </div>
      </div>
    </div>
  </div>
</section>
浓遮罩tone=strong

tone=strong:遮挡更强,用于需要专注的确认;默认展开以便直接对照两层浓度。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Overlay.html · strong
<section class="demo-block" data-demo="strong">
  <p class="demo-label">浓遮罩(tone=strong:遮挡强、适合需要专注的确认,默认展开对照)</p>
  <div class="demo-frame" data-assert="overlay-strong">
    <div class="kole-m-overlay kole-m-overlay--strong is-open" id="overlay-strong" role="presentation"
         data-open="true">
      <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
      <div class="kole-m-overlay__content">
        <p class="demo-card">
          <button class="demo-close" type="button" id="overlay-strong-close">点遮罩或此处关闭</button>
        </p>
      </div>
    </div>
  </div>
</section>
模糊遮罩tone=blur

tone=blur:背景内容做 backdrop 模糊,用于图片预览;模糊只影响遮罩下方,内容卡片保持清晰。

查看代码(演示页原文 · 19 行)
frameworks-mobile/Overlay.html · blur
<section class="demo-block" data-demo="blur">
  <p class="demo-label">模糊遮罩(tone=blur:背景内容做 backdrop 模糊,用于图片预览)</p>
  <div class="demo-frame" data-assert="overlay-blur">
    <div class="demo-frame__body">
      <p style="margin: 0 0 8px;">这一行是遮罩**下方**的内容,blur 档会把它糊掉。</p>
      <p style="margin: 0;">第二行同样是背景,用于对照模糊前后的可读性差别。</p>
    </div>
    <div class="kole-m-overlay kole-m-overlay--blur is-open" id="overlay-blur" role="presentation"
         data-open="true">
      <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
      <div class="kole-m-overlay__content">
        <div class="demo-card">
          <p class="demo-card__title">图片预览</p>
          <p class="demo-card__text">模糊档只影响遮罩下方,内容卡片本身保持清晰。</p>
        </div>
      </div>
    </div>
  </div>
</section>

02 组件状态

局部遮罩contained=true

contained=true:绝对定位填充最近的定位祖先,只遮住一张卡,页面其它区域仍可操作。

查看代码(演示页原文 · 19 行)
frameworks-mobile/Overlay.html · contained
<section class="demo-block" data-demo="contained">
  <p class="demo-label">局部遮罩(contained=true:绝对定位填充最近的定位祖先,不覆盖全屏)</p>
  <div class="demo-frame" data-assert="overlay-contained">
    <div class="demo-frame__body">
      <p style="margin: 0 0 8px;">卡片加载态:只遮住这张卡,页面其它区域仍可操作。</p>
      <div class="demo-card" style="width: auto; position: relative;">
        <p class="demo-card__title">数据概览</p>
        <p class="demo-card__text">本月成交 128 单 · 环比 +12%</p>
        <div class="kole-m-overlay kole-m-overlay--default kole-m-overlay--contained is-open"
             role="presentation" data-open="true">
          <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
          <div class="kole-m-overlay__content">
            <span class="demo-card__text">加载中…</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</section>
遮罩不关闭closeOnMask=false

closeOnMask=false:误触遮罩不收起;填到一半的表单不该因为一次误触就丢弃已填数据。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Overlay.html · keep-open
<section class="demo-block" data-demo="keep-open">
  <p class="demo-label">遮罩不关闭(closeOnMask=false:误触遮罩不收起,默认展开)</p>
  <div class="demo-frame" data-assert="overlay-keep-open">
    <div class="kole-m-overlay kole-m-overlay--default is-open" id="overlay-keep-open"
         role="presentation" data-open="true" data-close-on-mask="false">
      <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
      <div class="kole-m-overlay__content">
        <div class="demo-card">
          <p class="demo-card__title">填写中</p>
          <p class="demo-card__text">填到一半的表单不该因为一次误触就丢弃,所以关掉遮罩点击。</p>
          <button class="demo-close" type="button" id="overlay-keep-open-close">关闭</button>
        </div>
      </div>
    </div>
  </div>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
openbooleanfalse状态 open:展开且拦下所有手势(规格 §40.4)N
containedbooleanfalse变体 contained:绝对定位填充最近的定位祖先,做卡内局部遮罩(规格 §40.3)N
tone'default' | 'strong' | 'blur''default'变体 tone:遮罩浓度与质感(规格 §40.3)N
lockScrollbooleantrue是否锁住下层滚动;组件只写 data-lock-scroll 标记(规格 §40.5)N
closeOnMaskbooleantrue遮罩点击是否关闭;表单类内容通常设为 false(规格 §40.5)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
close—点击遮罩(closeOnMask=true 时)触发;是否收起由宿主决定(规格 §40.5)

插槽

名称说明
default遮罩之上的内容;语义(role / aria-modal)由宿主提供(规格 §40.2 content)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-overlay-blur6px组件内部默认值,可在业务侧覆盖
--kole-m-overlay-durationvar(--kole-m-duration-slide)组件内部默认值,可在业务侧覆盖

何时使用

  • 浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)
  • 与弹出层的分工:弹出层是能独立使用的完整浮层,遮罩层只提供「变暗 + 拦手势 + 承载任意内容」
  • 触屏上遮罩必须真的吃掉触摸事件,否则惯性滚动会从遮罩底下穿过去把下层页面滚走
  • 遮罩关闭的键盘路径不落在遮罩上:遮罩显式 tabindex="-1",键盘用户靠内容里的关闭按钮
  • lockScroll=true 时组件只在根上写 data-lock-scroll 标记,实际的 overflow: hidden 由宿主执行

交互与触控

  • 一次轻点遮罩即关闭;closeOnMask=false 时不关闭
  • 遮罩显式 tabindex="-1",不进键盘序列;键盘用户靠内容里的关闭按钮与 Esc 关闭
  • 开合动效 240ms(--kole-m-duration-slide),prefers-reduced-motion 下瞬时切换
  • contained=true 时要求宿主祖先链上存在定位元素,否则会向上找到视口

无障碍

  • 遮罩面 aria-hidden="true"(纯装饰)且 tabindex="-1"(不进键盘序列)
  • 根 role="presentation":容器本身不产生语义,避免读屏把它当成一个空的分组
  • 内容语义完全由宿主提供:焦点陷阱、aria-modal、初始焦点与关闭后焦点归还都属于宿主职责
  • 遮罩关闭不依赖颜色:开合只改透明度且有 240ms 过渡

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
弹出层Popup需要带方向位移与标题栏的完整浮层时用弹出层,遮罩层只做底座不负责内容结构
对话框Dialog需要确认或输入的中断式浮层用对话框,它自己已含遮罩与按钮组
加载Loading局部加载态需要转圈图标时用加载组件,遮罩层只提供变暗与拦手势

规格未定 / 禁止发明

类别条目
禁止发明焦点陷阱(focus trap)与初始焦点策略
禁止发明多层遮罩的层叠顺序管理
禁止发明手势下滑关闭与拖拽阻尼
禁止发明滚动锁定的实现细节(组件只写标记,改 DOM 由宿主做)
规格未定blur 档在低端机上的性能开销是否可接受(未做降级探测)
规格未定contained=true 时是否应自动为宿主补 position: relative
规格未定是否要支持「点遮罩不关但双击关」这类折中策略

结构(anatomy)

字段说明
overlay根元素,定位容器与开合开关;role="presentation",自身不承担语义
scrim遮罩面(原生 button),aria-hidden="true" 且 tabindex="-1" —— 可点但不进键盘序列
content内容容器(默认插槽落点),语义由宿主决定(对话框给 role="dialog"、面板给 role="region")

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
tonedefault / strong / blur
default .kole-m-overlay--default
strong .kole-m-overlay--strong
blur .kole-m-overlay--blur
containedfalse / true
false (由数据驱动,无专属类)
true .kole-m-overlay--contained

代表变体

变体标签
tone=default · contained=false基础全屏遮罩
tone=strong · contained=false浓遮罩(需要专注的确认)
tone=blur · contained=true模糊 + 局部(卡内加载态)

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-duration-slide --kole-m-ease-slide --kole-color-mask --kole-color-mask-strong --kole-m-overlay-blur --kole-m-overlay-duration

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/Overlay.css · 纯样式(CSS) · 66 行
frameworks-mobile/Overlay.css
/* Kole UI Mobile · Overlay 样式 — 对齐移动端规格 §40
   遮罩层:浮层基座。它自己不承载内容语义 —— scrim 是纯装饰(aria-hidden),
   内容放进 content 槽位并由宿主给 role(对话框 role=dialog、面板 role=region …)。
   两种尺寸:默认固定全屏(fixed);contained=true 时改为绝对定位填充最近的定位祖先,
   用于「卡片内局部加载遮罩」这类不覆盖全屏的场景。
   注意:scrim 是真·指针目标,但**不写 cursor: pointer** —— 触屏没有指针形态,
   鼠标专属的交互提示会把「可点」这件事表达成只有鼠标用户能收到(键盘路径由
   content 里的关闭按钮与 Esc 承担)。 */

.kole-m-overlay {
  --kole-m-overlay-blur: 6px;
  --kole-m-overlay-duration: var(--kole-m-duration-slide);
  position: fixed;
  inset: 0;
  z-index: 2000;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--kole-m-overlay-duration) var(--kole-m-ease-slide);
}

.kole-m-overlay.is-open {
  opacity: 1;
  pointer-events: auto;
}

/* 变体 contained=true:填充最近的定位祖先(局部遮罩,如卡片加载态) */
.kole-m-overlay--contained {
  position: absolute;
  z-index: 10;
}

/* 变体 tone:三档遮罩浓度与质感 */
.kole-m-overlay--default .kole-m-overlay__scrim { background: var(--kole-color-mask); }
.kole-m-overlay--strong .kole-m-overlay__scrim { background: var(--kole-color-mask-strong); }

.kole-m-overlay--blur .kole-m-overlay__scrim {
  background: var(--kole-color-mask);
  -webkit-backdrop-filter: blur(var(--kole-m-overlay-blur));
  backdrop-filter: blur(var(--kole-m-overlay-blur));
}

.kole-m-overlay__scrim {
  position: absolute;
  inset: 0;
  border: 0;
  padding: 0;
}

.kole-m-overlay__content {
  position: relative;
  z-index: 1;
  box-sizing: border-box;
  max-width: 80%;
  max-height: 80%;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

@media (prefers-reduced-motion: reduce) {
  .kole-m-overlay { transition: none; }
}
frameworks-mobile/Overlay.html · H5 原生(无框架) · 192 行
frameworks-mobile/Overlay.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · Overlay(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Overlay.css">
<style>
  body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
  .demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
  .demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
  /* 展示框:transform 建立包含块,固定定位的遮罩限定在框内(生产环境落在视口上) */
  .demo-frame { position: relative; max-width: 375px; margin: 0 auto; height: 220px;
    overflow: hidden; transform: translateZ(0); background: var(--kole-color-page-bg);
    border-block: 1px solid var(--kole-color-border); }
  .demo-frame__body { padding: var(--kole-m-gutter); font-size: var(--kole-m-font-size-label);
    color: var(--kole-color-text-secondary); line-height: 1.7; }
  /* 遮罩上的内容:卡片底(不透明)——对比度按卡片底计算,不按遮罩半透明层 */
  .demo-card { box-sizing: border-box; width: 240px; padding: var(--kole-space-16);
    border-radius: var(--kole-radius-large); background: var(--kole-color-card-bg);
    color: var(--kole-color-text-body); box-shadow: var(--kole-shadow-high); }
  .demo-card__title { margin: 0 0 var(--kole-space-8); font-size: var(--kole-m-font-size-body);
    font-weight: 500; color: var(--kole-color-text-title); }
  .demo-card__text { margin: 0 0 var(--kole-space-12); font-size: var(--kole-m-font-size-label);
    color: var(--kole-color-text-secondary); line-height: 1.6; }
  .demo-trigger { min-height: var(--kole-m-touch-target); padding: 0 var(--kole-m-gutter);
    border: 1px solid var(--kole-color-border); border-radius: var(--kole-radius-base);
    background: var(--kole-color-card-bg); color: var(--kole-color-text-body);
    font-family: inherit; font-size: var(--kole-m-font-size-label); cursor: pointer; touch-action: manipulation; }
  .demo-trigger:focus-visible { outline: 2px solid var(--kole-color-focus-ring); outline-offset: 2px; }
  .demo-close { min-height: var(--kole-m-touch-target); padding: 0 var(--kole-m-gutter);
    border: 0; border-radius: var(--kole-radius-base); background: var(--kole-color-brand);
    color: var(--kole-color-text-inverse); font-family: inherit;
    font-size: var(--kole-m-font-size-label); cursor: pointer; touch-action: manipulation; }
  .demo-close:focus-visible { outline: 2px solid var(--kole-color-focus-ring); outline-offset: 2px; }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础遮罩(tone=default:点遮罩关闭;遮罩是纯装饰,内容语义由槽位承担)</p>
    <div class="demo-frame" data-assert="overlay-basic">
      <div class="demo-frame__body">
        <button class="demo-trigger" type="button" id="overlay-basic-trigger"
                data-behavior="click-toggles-class:#overlay-basic|is-open">打开遮罩</button>
      </div>
      <div class="kole-m-overlay kole-m-overlay--default" id="overlay-basic" role="presentation"
           data-open="false">
        <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
        <div class="kole-m-overlay__content">
          <div class="demo-card">
            <p class="demo-card__title">遮罩上的内容</p>
            <p class="demo-card__text">遮罩本身不带语义,role 与标题由内容自己给。</p>
            <button class="demo-close" type="button" id="overlay-basic-close">关闭</button>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="strong">
    <p class="demo-label">浓遮罩(tone=strong:遮挡强、适合需要专注的确认,默认展开对照)</p>
    <div class="demo-frame" data-assert="overlay-strong">
      <div class="kole-m-overlay kole-m-overlay--strong is-open" id="overlay-strong" role="presentation"
           data-open="true">
        <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
        <div class="kole-m-overlay__content">
          <p class="demo-card">
            <button class="demo-close" type="button" id="overlay-strong-close">点遮罩或此处关闭</button>
          </p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="blur">
    <p class="demo-label">模糊遮罩(tone=blur:背景内容做 backdrop 模糊,用于图片预览)</p>
    <div class="demo-frame" data-assert="overlay-blur">
      <div class="demo-frame__body">
        <p style="margin: 0 0 8px;">这一行是遮罩**下方**的内容,blur 档会把它糊掉。</p>
        <p style="margin: 0;">第二行同样是背景,用于对照模糊前后的可读性差别。</p>
      </div>
      <div class="kole-m-overlay kole-m-overlay--blur is-open" id="overlay-blur" role="presentation"
           data-open="true">
        <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
        <div class="kole-m-overlay__content">
          <div class="demo-card">
            <p class="demo-card__title">图片预览</p>
            <p class="demo-card__text">模糊档只影响遮罩下方,内容卡片本身保持清晰。</p>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="contained">
    <p class="demo-label">局部遮罩(contained=true:绝对定位填充最近的定位祖先,不覆盖全屏)</p>
    <div class="demo-frame" data-assert="overlay-contained">
      <div class="demo-frame__body">
        <p style="margin: 0 0 8px;">卡片加载态:只遮住这张卡,页面其它区域仍可操作。</p>
        <div class="demo-card" style="width: auto; position: relative;">
          <p class="demo-card__title">数据概览</p>
          <p class="demo-card__text">本月成交 128 单 · 环比 +12%</p>
          <div class="kole-m-overlay kole-m-overlay--default kole-m-overlay--contained is-open"
               role="presentation" data-open="true">
            <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
            <div class="kole-m-overlay__content">
              <span class="demo-card__text">加载中…</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="keep-open">
    <p class="demo-label">遮罩不关闭(closeOnMask=false:误触遮罩不收起,默认展开)</p>
    <div class="demo-frame" data-assert="overlay-keep-open">
      <div class="kole-m-overlay kole-m-overlay--default is-open" id="overlay-keep-open"
           role="presentation" data-open="true" data-close-on-mask="false">
        <button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
        <div class="kole-m-overlay__content">
          <div class="demo-card">
            <p class="demo-card__title">填写中</p>
            <p class="demo-card__text">填到一半的表单不该因为一次误触就丢弃,所以关掉遮罩点击。</p>
            <button class="demo-close" type="button" id="overlay-keep-open-close">关闭</button>
          </div>
        </div>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页交互:类名与 data-open 由**同一个函数**写入(避免视觉与状态分叉)。
     遮罩点击只在 closeOnMask !== 'false' 时生效;关闭按钮始终生效。 */
  (function () {
    function setOpen(root, open) {
      root.classList.toggle('is-open', open);
      root.setAttribute('data-open', open ? 'true' : 'false');
    }
    var basic = document.getElementById('overlay-basic');
    var trig = document.getElementById('overlay-basic-trigger');
    if (basic && trig) {
      trig.addEventListener('click', function () {
        setOpen(basic, !basic.classList.contains('is-open'));
      });
      var close = document.getElementById('overlay-basic-close');
      if (close) close.addEventListener('click', function () { setOpen(basic, false); });
    }
    var strong = document.getElementById('overlay-strong');
    var strongClose = document.getElementById('overlay-strong-close');
    if (strong && strongClose) strongClose.addEventListener('click', function () { setOpen(strong, false); });
    var keep = document.getElementById('overlay-keep-open');
    var keepClose = document.getElementById('overlay-keep-open-close');
    if (keep && keepClose) keepClose.addEventListener('click', function () { setOpen(keep, false); });
    document.querySelectorAll('.kole-m-overlay__scrim').forEach(function (scrim) {
      scrim.addEventListener('click', function () {
        var root = scrim.parentElement;
        if (!root || root.getAttribute('data-close-on-mask') === 'false') return;
        setOpen(root, false);
      });
    });
  })();
</script>
<script>
  /* ?demo=<id> → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
  (function () {
    var id = new URLSearchParams(location.search).get('demo');
    if (!id) return;
    var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
    var hit = false;
    blocks.forEach(function (b) {
      var on = b.getAttribute('data-demo') === id;
      if (on) hit = true;
      b.hidden = !on;
    });
    if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
    document.body.classList.add('demo-single');
    var demoBox = document.querySelector('.demo');
    if (demoBox) demoBox.style.minHeight = 'auto';
    blocks.forEach(function (b) {
      var label = b.querySelector('.demo-label');
      if (label && !b.hidden) label.hidden = true;
    });
  })();
</script>
</body>
</html>
frameworks-mobile/Overlay.jsx · React · 45 行
frameworks-mobile/Overlay.jsx
import React from 'react';
import './Overlay.css';

/* 遮罩层(移动端)— 规格 §40
   浮层基座:遮罩本身是纯装饰(aria-hidden="true"),内容语义由宿主放进默认插槽并自己给 role。
   遮罩用原生 button 承载点击(button 天然可聚焦),但显式 tabIndex={-1} 让它**不进键盘序列** ——
   关闭的键盘路径由内容里的关闭按钮与 Esc 承担,靠 Tab 停在遮罩上会让读屏读到空名元素。
   受控:本端不存开合,只回传关闭意图 onClose,是否收起由宿主决定。 */
export default function Overlay({
  open = false,
  contained = false,
  tone = 'default',
  lockScroll = true,
  closeOnMask = true,
  onClose,
  children = null,
}) {
  const cls =
    'kole-m-overlay' +
    ` kole-m-overlay--${tone}` +
    (contained ? ' kole-m-overlay--contained' : '') +
    (open ? ' is-open' : '');

  return (
    <div
      className={cls}
      role="presentation"
      data-open={open ? 'true' : 'false'}
      data-lock-scroll={lockScroll ? 'true' : 'false'}
    >
      <button
        className="kole-m-overlay__scrim"
        type="button"
        aria-hidden="true"
        tabIndex={-1}
        onClick={() => {
          if (!closeOnMask) return;
          if (onClose) onClose();
        }}
      />
      <div className="kole-m-overlay__content">{children}</div>
    </div>
  );
}
frameworks-mobile/Overlay.vue2.vue · Vue 2 · 49 行
frameworks-mobile/Overlay.vue2.vue
<template>
  <div
    class="kole-m-overlay"
    :class="overlayClass"
    role="presentation"
    :data-open="open ? 'true' : 'false'"
    :data-lock-scroll="lockScroll ? 'true' : 'false'"
  >
    <button
      class="kole-m-overlay__scrim"
      type="button"
      aria-hidden="true"
      tabindex="-1"
      @click="onScrimClick"
    ></button>
    <div class="kole-m-overlay__content"><slot></slot></div>
  </div>
</template>

<script>
export default {
  name: 'KoleMOverlay',
  props: {
    open: { type: Boolean, default: false },
    contained: { type: Boolean, default: false },
    tone: { type: String, default: 'default' },
    lockScroll: { type: Boolean, default: true },
    closeOnMask: { type: Boolean, default: true }
  },
  computed: {
    overlayClass: function () {
      return [
        'kole-m-overlay--' + this.tone,
        this.contained ? 'kole-m-overlay--contained' : '',
        this.open ? 'is-open' : ''
      ].filter(Boolean);
    }
  },
  methods: {
    onScrimClick: function () {
      if (!this.closeOnMask) return;
      this.$emit('close');
    }
  }
};
</script>

<style src="./Overlay.css"></style>
frameworks-mobile/Overlay.vue3.vue · Vue 3 · 45 行
frameworks-mobile/Overlay.vue3.vue
<template>
  <div
    class="kole-m-overlay"
    :class="overlayClass"
    role="presentation"
    :data-open="open ? 'true' : 'false'"
    :data-lock-scroll="lockScroll ? 'true' : 'false'"
  >
    <button
      class="kole-m-overlay__scrim"
      type="button"
      aria-hidden="true"
      tabindex="-1"
      @click="onScrimClick"
    ></button>
    <div class="kole-m-overlay__content"><slot></slot></div>
  </div>
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  open: { type: Boolean, default: false },
  contained: { type: Boolean, default: false },
  tone: { type: String, default: 'default' },
  lockScroll: { type: Boolean, default: true },
  closeOnMask: { type: Boolean, default: true }
});
const emit = defineEmits(['close']);

const overlayClass = computed(() => [
  `kole-m-overlay--${props.tone}`,
  props.contained ? 'kole-m-overlay--contained' : '',
  props.open ? 'is-open' : ''
].filter(Boolean));

function onScrimClick() {
  if (!props.closeOnMask) return;
  emit('close');
}
</script>

<style src="./Overlay.css"></style>
frameworks-mobile/Overlay.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 94 行
frameworks-mobile/Overlay.uniapp.vue
<template>
  <view
    class="kole-m-overlay"
    :class="overlayClass"
    :data-open="open ? 'true' : 'false'"
    :data-lock-scroll="lockScroll ? 'true' : 'false'"
  >
    <view
      class="kole-m-overlay__scrim"
      aria-hidden="true"
      @tap="onScrimTap"
    ></view>
    <view class="kole-m-overlay__content"><slot></slot></view>
  </view>
</template>

<script setup>
/* uni-app 端 · 遮罩层(移动端)— 规格 §40
   跨端差异:遮罩用 view + @tap(小程序没有可点但不进 Tab 序列的原生 button 写法);
   它 aria-hidden,因此不承担任何可访问名称,关闭的键盘路径由内容里的按钮承担。
   尺寸用 rpx(2rpx ≈ 1px)。 */
import { computed } from 'vue';

const props = defineProps({
  open: { type: Boolean, default: false },
  contained: { type: Boolean, default: false },
  tone: { type: String, default: 'default' },
  lockScroll: { type: Boolean, default: true },
  closeOnMask: { type: Boolean, default: true }
});
const emit = defineEmits(['close']);

const overlayClass = computed(() => [
  `kole-m-overlay--${props.tone}`,
  props.contained ? 'kole-m-overlay--contained' : '',
  props.open ? 'is-open' : ''
].filter(Boolean));

function onScrimTap() {
  if (!props.closeOnMask) return;
  emit('close');
}
</script>

<style>
.kole-m-overlay {
  --kole-m-overlay-blur: 12rpx;
  --kole-m-overlay-duration: 240ms;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
}

.kole-m-overlay.is-open { opacity: 1; }

.kole-m-overlay--contained {
  position: absolute;
  z-index: 10;
}

.kole-m-overlay--default .kole-m-overlay__scrim { background-color: var(--kole-color-mask); }
.kole-m-overlay--strong .kole-m-overlay__scrim { background-color: var(--kole-color-mask-strong); }

.kole-m-overlay--blur .kole-m-overlay__scrim {
  background-color: var(--kole-color-mask);
  backdrop-filter: blur(var(--kole-m-overlay-blur));
}

.kole-m-overlay__scrim {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

.kole-m-overlay__content {
  position: relative;
  z-index: 1;
  box-sizing: border-box;
  max-width: 80%;
  max-height: 80%;
  overflow: auto;
}
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 16 条 · 全部通过 报告 2026-09-22 23:01:05

复现命令
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs   # 全量 5 个组件
npm run verify:mobile-docs                                           # 本页内容完整性 + API 与源码一致性

设计契约

components/mobile-overlay.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "40 · 遮罩层 Overlay",
  "confidence": "high",
  "slug": "mobile-overlay",
  "name": "遮罩层 Overlay",
  "semanticTypeCandidates": [
    "overlay",
    "scrim",
    "mask"
  ],
  "variantDimensions": [
    {
      "name": "tone",
      "values": [
        "default",
        "strong",
        "blur"
      ]
    },
    {
      "name": "contained",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "tone": "default",
      "contained": "false",
      "label": "基础全屏遮罩"
    },
    {
      "tone": "strong",
      "contained": "false",
      "label": "浓遮罩(需要专注的确认)"
    },
    {
      "tone": "blur",
      "contained": "true",
      "label": "模糊 + 局部(卡内加载态)"
    }
  ],
  "anatomy": {
    "overlay": "根元素,定位容器与开合开关;role=\"presentation\",自身不承担语义",
    "scrim": "遮罩面(原生 button),aria-hidden=\"true\" 且 tabindex=\"-1\" —— 可点但不进键盘序列",
    "content": "内容容器(默认插槽落点),语义由宿主决定(对话框给 role=\"dialog\"、面板给 role=\"region\")"
  },
  "structurePatterns": {
    "tone": "default 普通 / strong 浓遮罩 / blur 叠加背景模糊",
    "contained": "false 固定全屏 / true 绝对定位填充最近的定位祖先",
    "状态类": "is-open 展开(视觉与手势同时让开)"
  },
  "usageHints": [
    "浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)",
    "与弹出层的分工:弹出层是能独立使用的完整浮层,遮罩层只提供「变暗 + 拦手势 + 承载任意内容」",
    "触屏上遮罩必须真的吃掉触摸事件,否则惯性滚动会从遮罩底下穿过去把下层页面滚走",
    "遮罩关闭的键盘路径不落在遮罩上:遮罩显式 tabindex=\"-1\",键盘用户靠内容里的关闭按钮",
    "lockScroll=true 时组件只在根上写 data-lock-scroll 标记,实际的 overflow: hidden 由宿主执行"
  ],
  "doNotInvent": [
    "焦点陷阱(focus trap)与初始焦点策略",
    "多层遮罩的层叠顺序管理",
    "手势下滑关闭与拖拽阻尼",
    "滚动锁定的实现细节(组件只写标记,改 DOM 由宿主做)"
  ],
  "unknowns": [
    "blur 档在低端机上的性能开销是否可接受(未做降级探测)",
    "contained=true 时是否应自动为宿主补 position: relative",
    "是否要支持「点遮罩不关但双击关」这类折中策略"
  ],
  "interaction": [
    "一次轻点遮罩即关闭;closeOnMask=false 时不关闭",
    "遮罩显式 tabindex=\"-1\",不进键盘序列;键盘用户靠内容里的关闭按钮与 Esc 关闭",
    "开合动效 240ms(--kole-m-duration-slide),prefers-reduced-motion 下瞬时切换",
    "contained=true 时要求宿主祖先链上存在定位元素,否则会向上找到视口"
  ],
  "accessibility": [
    "遮罩面 aria-hidden=\"true\"(纯装饰)且 tabindex=\"-1\"(不进键盘序列)",
    "根 role=\"presentation\":容器本身不产生语义,避免读屏把它当成一个空的分组",
    "内容语义完全由宿主提供:焦点陷阱、aria-modal、初始焦点与关闭后焦点归还都属于宿主职责",
    "遮罩关闭不依赖颜色:开合只改透明度且有 240ms 过渡"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "open",
        "type": "boolean",
        "default": "false",
        "desc": "状态 open:展开且拦下所有手势(规格 §40.4)",
        "required": false
      },
      {
        "name": "contained",
        "type": "boolean",
        "default": "false",
        "desc": "变体 contained:绝对定位填充最近的定位祖先,做卡内局部遮罩(规格 §40.3)",
        "required": false
      },
      {
        "name": "tone",
        "type": "'default' | 'strong' | 'blur'",
        "default": "'default'",
        "desc": "变体 tone:遮罩浓度与质感(规格 §40.3)",
        "required": false
      },
      {
        "name": "lockScroll",
        "type": "boolean",
        "default": "true",
        "desc": "是否锁住下层滚动;组件只写 data-lock-scroll 标记(规格 §40.5)",
        "required": false
      },
      {
        "name": "closeOnMask",
        "type": "boolean",
        "default": "true",
        "desc": "遮罩点击是否关闭;表单类内容通常设为 false(规格 §40.5)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "close",
        "params": "—",
        "desc": "点击遮罩(closeOnMask=true 时)触发;是否收起由宿主决定(规格 §40.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "遮罩之上的内容;语义(role / aria-modal)由宿主提供(规格 §40.2 content)"
      }
    ]
  },
  "variantClasses": {
    "tone": {
      "default": [
        ".kole-m-overlay--default"
      ],
      "strong": [
        ".kole-m-overlay--strong"
      ],
      "blur": [
        ".kole-m-overlay--blur"
      ]
    },
    "contained": {
      "false": [],
      "true": [
        ".kole-m-overlay--contained"
      ]
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础遮罩",
      "desc": "tone=default:遮罩本身不带语义,role 与标题由内容自己给;一次轻点遮罩即关闭。",
      "variant": "tone=default"
    },
    {
      "id": "strong",
      "group": "01 组件类型",
      "title": "浓遮罩",
      "desc": "tone=strong:遮挡更强,用于需要专注的确认;默认展开以便直接对照两层浓度。",
      "variant": "tone=strong"
    },
    {
      "id": "blur",
      "group": "01 组件类型",
      "title": "模糊遮罩",
      "desc": "tone=blur:背景内容做 backdrop 模糊,用于图片预览;模糊只影响遮罩下方,内容卡片保持清晰。",
      "variant": "tone=blur"
    },
    {
      "id": "contained",
      "group": "02 组件状态",
      "title": "局部遮罩",
      "desc": "contained=true:绝对定位填充最近的定位祖先,只遮住一张卡,页面其它区域仍可操作。",
      "variant": "contained=true"
    },
    {
      "id": "keep-open",
      "group": "02 组件状态",
      "title": "遮罩不关闭",
      "desc": "closeOnMask=false:误触遮罩不收起;填到一半的表单不该因为一次误触就丢弃已填数据。",
      "variant": "closeOnMask=false"
    }
  ],
  "related": [
    {
      "slug": "mobile-popup",
      "why": "需要带方向位移与标题栏的完整浮层时用弹出层,遮罩层只做底座不负责内容结构"
    },
    {
      "slug": "mobile-dialog",
      "why": "需要确认或输入的中断式浮层用对话框,它自己已含遮罩与按钮组"
    },
    {
      "slug": "mobile-loading",
      "why": "局部加载态需要转圈图标时用加载组件,遮罩层只提供变暗与拦手势"
    }
  ]
}