移动端导航吸顶容器

吸顶容器Sticky

让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)

导航 规格 39 · 吸顶容器 Sticky 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-sticky.css">

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

演示

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

01 组件类型

基础吸顶position=top

position: sticky 的默认形态:在框内向下滚动时标题条贴住框顶,不脱离文档流因此无需补占位元素。

查看代码(演示页原文 · 19 行)
frameworks-mobile/Sticky.html · basic
<section class="demo-block" data-demo="basic">
  <p class="demo-label">基础吸顶(position: sticky + 默认偏移 = 导航栏高度:在框内滚动,标题条贴住导航栏下沿)</p>
  <div class="demo-scroll" id="scroll-basic" data-assert="sticky-basic">
    <div class="demo-navbar" aria-hidden="true">模拟固定导航栏(高 44px)</div>
    <div class="demo-row" style="height: 88px">(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)</div>
    <div class="kole-m-sticky kole-m-sticky--top" id="sticky-basic" data-stuck="false">
      <span class="kole-m-sticky__title">订单列表 · 共 6 条</span>
      <span class="kole-m-sticky__extra">9 月</span>
    </div>
    <div class="demo-row">20260920-001 · 已发货</div>
    <div class="demo-row">20260920-002 · 待付款</div>
    <div class="demo-row">20260919-014 · 已完成</div>
    <div class="demo-row">20260919-013 · 已完成</div>
    <div class="demo-row">20260918-007 · 已取消</div>
    <div class="demo-row">20260918-006 · 已完成</div>
    <div class="demo-row">20260917-003 · 已完成</div>
  </div>
  <p class="demo-hint">滚动后标题条贴在导航栏下沿保持可见(sticky 不脱离文档流,因此无需补占位元素)</p>
</section>
吸顶后出阴影shadow=true

shadow=true:未吸顶时与内容齐平,贴合后才出现投影,用形态而不是颜色表达「已经贴住了」。

查看代码(演示页原文 · 21 行)
frameworks-mobile/Sticky.html · shadow
<section class="demo-block" data-demo="shadow">
  <p class="demo-label">吸顶后出阴影(shadow=true:未吸顶时与内容齐平,贴合后才有投影)</p>
  <div class="demo-scroll" id="scroll-shadow" data-assert="sticky-shadow">
    <div class="demo-navbar" aria-hidden="true">模拟固定导航栏(高 44px)</div>
    <div class="demo-row" style="height: 88px">(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)</div>
    <div class="kole-m-sticky kole-m-sticky--top kole-m-sticky--shadow" id="sticky-shadow" data-stuck="false">
      <span class="kole-m-sticky__title">分组:待处理</span>
    </div>
    <div class="demo-row">工单 #4821 · 待分派</div>
    <div class="demo-row">工单 #4822 · 待分派</div>
    <div class="demo-row">工单 #4823 · 处理中</div>
    <div class="demo-row">工单 #4824 · 处理中</div>
    <div class="demo-row">工单 #4825 · 待回访</div>
    <div class="demo-row">工单 #4826 · 待回访</div>
  </div>
  <p class="demo-hint">
    <button class="kole-m-sticky__action" type="button" id="sticky-shadow-toggle"
            data-behavior="click-toggles-class:#sticky-shadow|is-stuck">切换 is-stuck 对照</button>
    手动切换贴合态,可直接对照有/无阴影两种形态
  </p>
</section>
安全区叠加safeArea=true

safeArea=true:偏移量再叠加刘海高度;无刘海设备上安全区为 0px,表现与不带 safe 一致。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Sticky.html · safe-area
<section class="demo-block" data-demo="safe-area">
  <p class="demo-label">安全区叠加(safeArea=true:偏移 = 容器偏移 + 刘海高度,无刘海时为 0px)</p>
  <div class="demo-scroll" id="scroll-safe" data-assert="sticky-safe-area">
    <div class="kole-m-sticky kole-m-sticky--top kole-m-sticky--safe is-stuck" data-stuck="true"
         style="--kole-m-sticky-offset: 0px">
      <span class="kole-m-sticky__title">安全区吸顶(覆盖 offset=0)</span>
    </div>
    <div class="demo-row">覆盖 --kole-m-sticky-offset: 0px 后贴合在框顶</div>
    <div class="demo-row">真实设备上再叠加 env(safe-area-inset-top)</div>
    <div class="demo-row">顶部安全区为 0px 时与不带 safe 的表现一致</div>
    <div class="demo-row">内容行 4</div>
    <div class="demo-row">内容行 5</div>
  </div>
</section>

02 组件状态

贴底吸顶position=bottom

position=bottom:内容不足一屏时贴住容器底,常用于订单合计条;吸顶后描边换到上边。

查看代码(演示页原文 · 13 行)
frameworks-mobile/Sticky.html · bottom
<section class="demo-block" data-demo="bottom">
  <p class="demo-label">贴底吸顶(position=bottom:内容不足一屏时贴住框底,常用于底部合计条)</p>
  <div class="demo-scroll" id="scroll-bottom" data-assert="sticky-bottom">
    <div class="demo-row">商品 1 · ¥128.00</div>
    <div class="demo-row">商品 2 · ¥256.00</div>
    <div class="demo-row">商品 3 · ¥64.00</div>
    <div class="kole-m-sticky kole-m-sticky--bottom kole-m-sticky--shadow" data-stuck="true"
         style="--kole-m-sticky-offset: 0px">
      <span class="kole-m-sticky__title">合计 ¥448.00</span>
      <span class="kole-m-sticky__extra">已减 ¥12</span>
    </div>
  </div>
</section>
带动作actionText 非空

右侧动作是原生 button、热区 44px;点击回传 action,具体做什么由宿主决定。

查看代码(演示页原文 · 15 行)
frameworks-mobile/Sticky.html · action
<section class="demo-block" data-demo="action">
  <p class="demo-label">带动作(右侧动作是原生 button,热区 44px;点击回传 action 由宿主处理)</p>
  <div class="demo-scroll" id="scroll-action" data-assert="sticky-action">
    <div class="kole-m-sticky kole-m-sticky--top is-stuck" id="sticky-action" data-stuck="true">
      <span class="kole-m-sticky__title">收货地址</span>
      <button class="kole-m-sticky__action" type="button"
              data-behavior="click-sets-attr:#sticky-action|data-clicked|true">管理</button>
    </div>
    <div class="demo-row">浙江省杭州市余杭区文一西路 969 号</div>
    <div class="demo-row">收货人:张* | 138****8841</div>
    <div class="demo-row">内容行 3</div>
    <div class="demo-row">内容行 4</div>
    <div class="demo-row">内容行 5</div>
  </div>
</section>

API

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

Props

名称类型默认值说明必传
position'top' | 'bottom''top'变体 position:贴顶还是贴底(规格 §39.3)N
safeAreabooleanfalse变体 safeArea:偏移叠加刘海 / 底部横条安全区(规格 §39.3)N
shadowbooleanfalse变体 shadow:仅吸顶后才有投影(规格 §39.3)N
stuckbooleanfalse状态 stuck:是否已贴合边缘,由宿主按滚动位置传入(规格 §39.4)N
titlestring''标题文字,单行省略(规格 §39.2 title)N
actionTextstring''右侧动作按钮文字;为空时不渲染按钮(规格 §39.2 action)N

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

事件

名称参数说明
action—点击右侧动作按钮时触发(规格 §39.5)

插槽

名称说明
default额外的栏内内容(放在标题与动作之间)

CSS 变量

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

名称默认值说明
--kole-m-sticky-offsetvar(--kole-m-navbar-height)组件内部默认值,可在业务侧覆盖

何时使用

  • 让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)
  • 用 CSS 原生 position: sticky —— 触屏惯性滚动下 JS 的 fixed 方案会抖动,且脱离文档流后要补占位元素
  • 贴合位置由组件级变量 --kole-m-sticky-offset 决定,默认等于导航栏高度
  • 组件本身不监听滚动:is-stuck 由宿主按滚动位置切换
  • 吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致

交互与触控

  • 贴合位置由组件级变量 --kole-m-sticky-offset 决定,业务侧覆盖它即可适配自有导航
  • safeArea=true 时偏移叠加 --kole-m-safe-top / --kole-m-safe-bottom;env() 不可用时为 0px
  • 组件本身不监听滚动:is-stuck 由宿主按滚动位置切换
  • 触屏热区:整条高度 ≥ 44px;右侧动作按钮自身撑满 44px
  • position=bottom 时吸顶后的描边换到上边

无障碍

  • 根是普通容器,标题文字正常参与读屏朗读;is-stuck 是纯视觉增强,不添加任何 aria-*
  • 右侧动作是原生 button,名称由可见文字承担;装饰性图形必须 aria-hidden="true"
  • 吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致
  • prefers-reduced-motion 下不引入任何过渡

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

组件何时用它而不是本组件
顶部导航栏NavBar页面级固定导航用导航栏(fixed 且带安全区与返回),区块内的贴合才用吸顶容器
单元格Cell吸顶条下面承载的列表项用单元格,吸顶容器只负责那一行的贴合行为
列表List需要分组标题列表时用列表组件,而不是给每一行都套一个吸顶容器

规格未定 / 禁止发明

类别条目
禁止发明吸顶触发的位移/缩放动画(规格只定义了贴合,没有定义形态演变)
禁止发明多个吸顶条的层叠顺序与相互推挤(层叠上下文规则由宿主决定)
禁止发明进入/离开视口时的埋点事件与曝光统计
禁止发明拖拽排序与吸附
规格未定吸顶判定是否应由组件内部提供一个可选的滚动监听辅助(当前完全交给宿主)
规格未定是否需要在吸顶时自动隐藏相邻内容(当前不做,靠宿主布局)
规格未定贴底形态在内容不足一屏时是否应始终贴底

结构(anatomy)

字段说明
sticky根元素,就是滚动内容流里的那一行(sticky 不脱离文档流,因此不需要占位元素)
title标题文字,单行省略,占满剩余宽度
extra右侧附加说明(数量、合计等),可选
action右侧动作按钮(原生 button,热区 44px),可选

变体维度与类名映射

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

维度取值对应类名 / 变量
positiontop / bottom
top .kole-m-sticky--top
bottom .kole-m-sticky--bottom
safeAreafalse / true
false (由数据驱动,无专属类)
true .kole-m-sticky--safe
shadowfalse / true
false (由数据驱动,无专属类)
true .kole-m-sticky--shadow

代表变体

变体标签
position=top · safeArea=false · shadow=false基础吸顶(列表标题条)
position=top · safeArea=false · shadow=true吸顶后出阴影(与内容分层)
position=top · safeArea=true · shadow=false安全区叠加(刘海屏)
position=bottom · safeArea=false · shadow=true贴底吸顶(合计条)

用到的令牌

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

--kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-m-navbar-height --kole-m-safe-bottom --kole-m-safe-top --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-brand-bg --kole-color-card-bg --kole-color-focus-ring --kole-color-text-secondary --kole-color-text-title --kole-font-family --kole-radius-base --kole-shadow-medium --kole-space-12 --kole-space-8 --kole-m-sticky-offset

6 端源码

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

frameworks-mobile/Sticky.css · 纯样式(CSS) · 97 行
frameworks-mobile/Sticky.css
/* Kole UI Mobile · Sticky 样式 — 对齐移动端规格 §39
   吸顶容器:用 CSS 原生 position: sticky(不是 JS 监听滚动 + position: fixed)——
   后者在触屏上会随惯性滚动抖动,且离开文档流后会占位丢失、需要手动补占位元素。
   sticky 的贴合位置由 `--kole-m-sticky-offset` 决定(默认等于导航栏高度,业务侧可覆盖),
   safeArea=true 时再叠加安全区;吸顶后的形态变化由宿主按滚动位置切 is-stuck。 */

.kole-m-sticky {
  /* 组件级变量:贴合边缘的偏移量(业务侧可覆盖,贴底用法通常覆盖为 0) */
  --kole-m-sticky-offset: var(--kole-m-navbar-height);
  position: sticky;
  z-index: 100;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: var(--kole-space-8);
  min-height: var(--kole-m-touch-target);
  padding: var(--kole-space-8) var(--kole-m-gutter);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-title);
  font-family: var(--kole-font-family);
  font-size: var(--kole-m-font-size-body);
  font-weight: 500;
  line-height: 1.4;
}

/* 变体 position:贴顶 / 贴底 */
.kole-m-sticky--top { top: var(--kole-m-sticky-offset); }
.kole-m-sticky--bottom { bottom: var(--kole-m-sticky-offset); }

/* 变体 safeArea=true:偏移叠加刘海 / 底部横条安全区(env() 不可用时安全区为 0px) */
.kole-m-sticky--top.kole-m-sticky--safe {
  top: calc(var(--kole-m-sticky-offset) + var(--kole-m-safe-top));
}

.kole-m-sticky--bottom.kole-m-sticky--safe {
  bottom: calc(var(--kole-m-sticky-offset) + var(--kole-m-safe-bottom));
}

/* 变体 shadow=true:吸顶后才出现的阴影(未吸顶时与内容齐平,不留悬空感) */
.kole-m-sticky--shadow { box-shadow: none; }

.kole-m-sticky--shadow.is-stuck { box-shadow: var(--kole-shadow-medium); }

/* 状态 is-stuck:已贴合边缘(由宿主按滚动位置切换) */
.kole-m-sticky.is-stuck {
  border-block-end: 1px solid var(--kole-color-border);
}

.kole-m-sticky--bottom.is-stuck {
  border-block-end: 0;
  border-block-start: 1px solid var(--kole-color-border);
}

.kole-m-sticky__title {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.kole-m-sticky__extra { flex: 0 0 auto; font-size: var(--kole-m-font-size-label); font-weight: 400; color: var(--kole-color-text-secondary); }

/* 容器内的动作按钮(可选):原生 button,热区 44px,负外边距吸收不撑高栏体 */
.kole-m-sticky__action {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: var(--kole-m-touch-target);
  height: var(--kole-m-touch-target);
  margin-inline-end: calc(-1 * var(--kole-space-12));
  padding: 0 var(--kole-space-8);
  border: 0;
  border-radius: var(--kole-radius-base);
  background: none;
  color: var(--kole-color-brand);
  font-family: inherit;
  font-size: var(--kole-m-font-size-label);
  font-weight: 400;
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-sticky__action:active { background: var(--kole-color-brand-bg); }

.kole-m-sticky__action:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: -2px;
}

@media (prefers-reduced-motion: reduce) {
  .kole-m-sticky { transition: none; }
}
frameworks-mobile/Sticky.html · H5 原生(无框架) · 196 行
frameworks-mobile/Sticky.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 · Sticky(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Sticky.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); }
  /* 滚动容器:sticky 的贴合参照是**最近的可滚动祖先**,所以在框内滚动就能看到吸顶效果 */
  .demo-scroll { position: relative; height: 200px; overflow-y: auto; -webkit-overflow-scrolling: touch;
    background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
  /* 模拟页面级固定导航:吸顶条的默认偏移 = 导航栏高度,恰好贴在它下沿 */
  .demo-navbar { position: absolute; top: 0; left: 0; right: 0; z-index: 2;
    display: flex; align-items: center; height: var(--kole-m-navbar-height);
    padding: 0 var(--kole-m-gutter); background: var(--kole-color-card-bg);
    border-block-end: 1px solid var(--kole-color-border);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
  .demo-row { padding: var(--kole-space-12) var(--kole-m-gutter);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary);
    border-bottom: 1px solid var(--kole-color-border); }
  .demo-hint { margin: 0; padding: var(--kole-space-8) var(--kole-m-gutter) 0;
    font-size: var(--kole-m-font-size-caption); color: var(--kole-color-text-tertiary); }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="basic">
    <p class="demo-label">基础吸顶(position: sticky + 默认偏移 = 导航栏高度:在框内滚动,标题条贴住导航栏下沿)</p>
    <div class="demo-scroll" id="scroll-basic" data-assert="sticky-basic">
      <div class="demo-navbar" aria-hidden="true">模拟固定导航栏(高 44px)</div>
      <div class="demo-row" style="height: 88px">(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)</div>
      <div class="kole-m-sticky kole-m-sticky--top" id="sticky-basic" data-stuck="false">
        <span class="kole-m-sticky__title">订单列表 · 共 6 条</span>
        <span class="kole-m-sticky__extra">9 月</span>
      </div>
      <div class="demo-row">20260920-001 · 已发货</div>
      <div class="demo-row">20260920-002 · 待付款</div>
      <div class="demo-row">20260919-014 · 已完成</div>
      <div class="demo-row">20260919-013 · 已完成</div>
      <div class="demo-row">20260918-007 · 已取消</div>
      <div class="demo-row">20260918-006 · 已完成</div>
      <div class="demo-row">20260917-003 · 已完成</div>
    </div>
    <p class="demo-hint">滚动后标题条贴在导航栏下沿保持可见(sticky 不脱离文档流,因此无需补占位元素)</p>
  </section>

  <section class="demo-block" data-demo="shadow">
    <p class="demo-label">吸顶后出阴影(shadow=true:未吸顶时与内容齐平,贴合后才有投影)</p>
    <div class="demo-scroll" id="scroll-shadow" data-assert="sticky-shadow">
      <div class="demo-navbar" aria-hidden="true">模拟固定导航栏(高 44px)</div>
      <div class="demo-row" style="height: 88px">(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)</div>
      <div class="kole-m-sticky kole-m-sticky--top kole-m-sticky--shadow" id="sticky-shadow" data-stuck="false">
        <span class="kole-m-sticky__title">分组:待处理</span>
      </div>
      <div class="demo-row">工单 #4821 · 待分派</div>
      <div class="demo-row">工单 #4822 · 待分派</div>
      <div class="demo-row">工单 #4823 · 处理中</div>
      <div class="demo-row">工单 #4824 · 处理中</div>
      <div class="demo-row">工单 #4825 · 待回访</div>
      <div class="demo-row">工单 #4826 · 待回访</div>
    </div>
    <p class="demo-hint">
      <button class="kole-m-sticky__action" type="button" id="sticky-shadow-toggle"
              data-behavior="click-toggles-class:#sticky-shadow|is-stuck">切换 is-stuck 对照</button>
      手动切换贴合态,可直接对照有/无阴影两种形态
    </p>
  </section>

  <section class="demo-block" data-demo="safe-area">
    <p class="demo-label">安全区叠加(safeArea=true:偏移 = 容器偏移 + 刘海高度,无刘海时为 0px)</p>
    <div class="demo-scroll" id="scroll-safe" data-assert="sticky-safe-area">
      <div class="kole-m-sticky kole-m-sticky--top kole-m-sticky--safe is-stuck" data-stuck="true"
           style="--kole-m-sticky-offset: 0px">
        <span class="kole-m-sticky__title">安全区吸顶(覆盖 offset=0)</span>
      </div>
      <div class="demo-row">覆盖 --kole-m-sticky-offset: 0px 后贴合在框顶</div>
      <div class="demo-row">真实设备上再叠加 env(safe-area-inset-top)</div>
      <div class="demo-row">顶部安全区为 0px 时与不带 safe 的表现一致</div>
      <div class="demo-row">内容行 4</div>
      <div class="demo-row">内容行 5</div>
    </div>
  </section>

  <section class="demo-block" data-demo="bottom">
    <p class="demo-label">贴底吸顶(position=bottom:内容不足一屏时贴住框底,常用于底部合计条)</p>
    <div class="demo-scroll" id="scroll-bottom" data-assert="sticky-bottom">
      <div class="demo-row">商品 1 · ¥128.00</div>
      <div class="demo-row">商品 2 · ¥256.00</div>
      <div class="demo-row">商品 3 · ¥64.00</div>
      <div class="kole-m-sticky kole-m-sticky--bottom kole-m-sticky--shadow" data-stuck="true"
           style="--kole-m-sticky-offset: 0px">
        <span class="kole-m-sticky__title">合计 ¥448.00</span>
        <span class="kole-m-sticky__extra">已减 ¥12</span>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="action">
    <p class="demo-label">带动作(右侧动作是原生 button,热区 44px;点击回传 action 由宿主处理)</p>
    <div class="demo-scroll" id="scroll-action" data-assert="sticky-action">
      <div class="kole-m-sticky kole-m-sticky--top is-stuck" id="sticky-action" data-stuck="true">
        <span class="kole-m-sticky__title">收货地址</span>
        <button class="kole-m-sticky__action" type="button"
                data-behavior="click-sets-attr:#sticky-action|data-clicked|true">管理</button>
      </div>
      <div class="demo-row">浙江省杭州市余杭区文一西路 969 号</div>
      <div class="demo-row">收货人:张* | 138****8841</div>
      <div class="demo-row">内容行 3</div>
      <div class="demo-row">内容行 4</div>
      <div class="demo-row">内容行 5</div>
    </div>
  </section>
</div>
<script>
  /* 演示页交互:滚动容器里的 sticky 贴合时机只有浏览器知道 —— 演示页用 scrollTop 判断,
     把 is-stuck / data-stuck 同步到**真源那一个函数**(避免类名与属性分叉)。
     生产环境同样由宿主驱动;组件本体不监听滚动。 */
  (function () {
    /* 贴合态的唯一写入点:类名与属性在同一个函数里落笔,避免「视觉贴住了、属性还说没贴」 */
    function setStuck(bar, stuck) {
      bar.classList.toggle('is-stuck', stuck);
      bar.setAttribute('data-stuck', stuck ? 'true' : 'false');
    }
    /* 贴合判定:比较吸顶条的实际 top 与「容器内容区 top + 偏移量」——
       贴合后两者相等,未贴合时吸顶条还在偏移量之下。
       容器内容区 top = 容器 border-box top + 上边框宽度(sticky 的偏移是相对 padding box 的,
       漏掉这 1px 会把「刚刚贴住」误判成「还没贴」)。判据不依赖硬编码的偏移数值。 */
    function isStuck(box, bar) {
      var offset = parseFloat(getComputedStyle(bar).top) || 0;
      var border = parseFloat(getComputedStyle(box).borderTopWidth) || 0;
      var boxTop = box.getBoundingClientRect().top;
      var barTop = bar.getBoundingClientRect().top;
      return barTop <= boxTop + border + offset + 0.5;
    }
    function bind(scrollSel, stickySel) {
      var box = document.querySelector(scrollSel);
      var bar = document.querySelector(stickySel);
      if (!box || !bar) return;
      function sync() {
        setStuck(bar, isStuck(box, bar));
      }
      box.addEventListener('scroll', sync, { passive: true });
      sync();
    }
    bind('#scroll-basic', '#sticky-basic');
    bind('#scroll-shadow', '#sticky-shadow');
    /* 对照开关:手动翻转贴合态,用于并排看有/无阴影两种形态 */
    var toggle = document.getElementById('sticky-shadow-toggle');
    var shadowBar = document.getElementById('sticky-shadow');
    if (toggle && shadowBar) {
      toggle.addEventListener('click', function () {
        setStuck(shadowBar, !shadowBar.classList.contains('is-stuck'));
      });
    }
  })();
  (function () {
    var bar = document.getElementById('sticky-action');
    if (!bar) return;
    var btn = bar.querySelector('.kole-m-sticky__action');
    if (!btn) return;
    btn.addEventListener('click', function () {
      bar.setAttribute('data-clicked', 'true');
    });
  })();
</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/Sticky.jsx · React · 44 行
frameworks-mobile/Sticky.jsx
import React from 'react';
import './Sticky.css';

/* 吸顶容器(移动端)— 规格 §39
   用 CSS 原生 position: sticky(不是 JS 监听滚动 + position: fixed):
   后者在触屏惯性滚动时会抖动,离开文档流后还要手动补占位元素。
   贴合偏移由 --kole-m-sticky-offset 决定(默认导航栏高度),safeArea=true 时叠加安全区。
   is-stuck 由**宿主**按滚动位置切换 —— 组件不监听滚动(sticky 的贴合判定只有浏览器知道)。 */
export default function Sticky({
  position = 'top',
  safeArea = false,
  shadow = false,
  stuck = false,
  title = '',
  actionText = '',
  onAction,
  children = null,
}) {
  const cls =
    'kole-m-sticky' +
    ` kole-m-sticky--${position}` +
    (safeArea ? ' kole-m-sticky--safe' : '') +
    (shadow ? ' kole-m-sticky--shadow' : '') +
    (stuck ? ' is-stuck' : '');

  return (
    <div className={cls} data-stuck={stuck ? 'true' : 'false'}>
      {title ? <span className="kole-m-sticky__title">{title}</span> : null}
      {children}
      {actionText ? (
        <button
          className="kole-m-sticky__action"
          type="button"
          onClick={() => {
            if (onAction) onAction();
          }}
        >
          {actionText}
        </button>
      ) : null}
    </div>
  );
}
frameworks-mobile/Sticky.vue2.vue · Vue 2 · 39 行
frameworks-mobile/Sticky.vue2.vue
<template>
  <div class="kole-m-sticky" :class="stickyClass" :data-stuck="stuck ? 'true' : 'false'">
    <span v-if="title" class="kole-m-sticky__title">{{ title }}</span>
    <slot></slot>
    <button
      v-if="actionText"
      class="kole-m-sticky__action"
      type="button"
      @click="$emit('action')"
    >{{ actionText }}</button>
  </div>
</template>

<script>
export default {
  name: 'KoleMSticky',
  props: {
    position: { type: String, default: 'top' },
    safeArea: { type: Boolean, default: false },
    shadow: { type: Boolean, default: false },
    stuck: { type: Boolean, default: false },
    title: { type: String, default: '' },
    actionText: { type: String, default: '' }
  },
  computed: {
    stickyClass: function () {
      return [
        'kole-m-sticky--' + this.position,
        this.safeArea ? 'kole-m-sticky--safe' : '',
        this.shadow ? 'kole-m-sticky--shadow' : '',
        this.stuck ? 'is-stuck' : ''
      ].filter(Boolean);
    }
  }
};
</script>

<style src="./Sticky.css"></style>
frameworks-mobile/Sticky.vue3.vue · Vue 3 · 40 行
frameworks-mobile/Sticky.vue3.vue
<template>
  <div class="kole-m-sticky" :class="stickyClass" :data-stuck="stuck ? 'true' : 'false'">
    <span v-if="title" class="kole-m-sticky__title">{{ title }}</span>
    <slot></slot>
    <button
      v-if="actionText"
      class="kole-m-sticky__action"
      type="button"
      @click="onActionClick"
    >{{ actionText }}</button>
  </div>
</template>

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

const props = defineProps({
  position: { type: String, default: 'top' },
  safeArea: { type: Boolean, default: false },
  shadow: { type: Boolean, default: false },
  stuck: { type: Boolean, default: false },
  title: { type: String, default: '' },
  actionText: { type: String, default: '' }
});
const emit = defineEmits(['action']);

const stickyClass = computed(() => [
  `kole-m-sticky--${props.position}`,
  props.safeArea ? 'kole-m-sticky--safe' : '',
  props.shadow ? 'kole-m-sticky--shadow' : '',
  props.stuck ? 'is-stuck' : ''
].filter(Boolean));

function onActionClick() {
  emit('action');
}
</script>

<style src="./Sticky.css"></style>
frameworks-mobile/Sticky.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 110 行
frameworks-mobile/Sticky.uniapp.vue
<template>
  <view class="kole-m-sticky" :class="stickyClass" :data-stuck="stuck ? 'true' : 'false'">
    <text v-if="title" class="kole-m-sticky__title">{{ title }}</text>
    <slot></slot>
    <view
      v-if="actionText"
      class="kole-m-sticky__action"
      role="button"
      @tap="onActionTap"
    >
      <text>{{ actionText }}</text>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 吸顶容器(移动端)— 规格 §39
   跨端差异:小程序 / App 端没有 CSS sticky 的可靠实现(部分内核把 sticky 退化为 static),
   因此本端**由宿主用 scroll-view 的 @scroll 事件**判断贴合时机并传 stuck;
   组件自身只负责形态(偏移量、安全区、吸顶后的阴影与描边)。
   尺寸用 rpx(2rpx ≈ 1px);偏移量靠 --kole-m-sticky-offset 覆盖。 */
import { computed } from 'vue';

const props = defineProps({
  position: { type: String, default: 'top' },
  safeArea: { type: Boolean, default: false },
  shadow: { type: Boolean, default: false },
  stuck: { type: Boolean, default: false },
  title: { type: String, default: '' },
  actionText: { type: String, default: '' }
});
const emit = defineEmits(['action']);

const stickyClass = computed(() => [
  `kole-m-sticky--${props.position}`,
  props.safeArea ? 'kole-m-sticky--safe' : '',
  props.shadow ? 'kole-m-sticky--shadow' : '',
  props.stuck ? 'is-stuck' : ''
].filter(Boolean));

function onActionTap() {
  emit('action');
}
</script>

<style>
.kole-m-sticky {
  --kole-m-sticky-offset: 88rpx;
  /* 安全区补偿:小程序 / App 端没有 env(),由宿主读系统安全区后覆盖这个变量 */
  --kole-m-sticky-safe-extra: 0rpx;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-body: 32rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  position: relative;
  z-index: 100;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  min-height: var(--kole-m-touch-target);
  padding: 16rpx var(--kole-m-gutter);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-title);
  font-size: var(--kole-m-font-size-body);
  font-weight: 500;
}

.kole-m-sticky--top { top: var(--kole-m-sticky-offset); }
.kole-m-sticky--bottom { bottom: var(--kole-m-sticky-offset); }

.kole-m-sticky--top.kole-m-sticky--safe {
  top: calc(var(--kole-m-sticky-offset) + var(--kole-m-sticky-safe-extra));
}

.kole-m-sticky--bottom.kole-m-sticky--safe {
  bottom: calc(var(--kole-m-sticky-offset) + var(--kole-m-sticky-safe-extra));
}

.kole-m-sticky.is-stuck {
  border-bottom: 2rpx solid var(--kole-color-border);
}

.kole-m-sticky--shadow.is-stuck {
  box-shadow: var(--kole-shadow-medium);
}

.kole-m-sticky__title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.kole-m-sticky__action {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: var(--kole-m-touch-target);
  height: var(--kole-m-touch-target);
  margin-right: -24rpx;
  padding: 0 16rpx;
  border-radius: 8rpx;
  color: var(--kole-color-brand);
  font-size: var(--kole-m-font-size-label);
  font-weight: 400;
}
</style>

测试与回归

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

断言 17 条 · 全部通过 报告 2026-09-20 16:50:33

复现命令
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-sticky.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "39 · 吸顶容器 Sticky",
  "confidence": "high",
  "slug": "mobile-sticky",
  "name": "吸顶容器 Sticky",
  "semanticTypeCandidates": [
    "sticky",
    "affix",
    "section-header"
  ],
  "variantDimensions": [
    {
      "name": "position",
      "values": [
        "top",
        "bottom"
      ]
    },
    {
      "name": "safeArea",
      "values": [
        "false",
        "true"
      ]
    },
    {
      "name": "shadow",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "position": "top",
      "safeArea": "false",
      "shadow": "false",
      "label": "基础吸顶(列表标题条)"
    },
    {
      "position": "top",
      "safeArea": "false",
      "shadow": "true",
      "label": "吸顶后出阴影(与内容分层)"
    },
    {
      "position": "top",
      "safeArea": "true",
      "shadow": "false",
      "label": "安全区叠加(刘海屏)"
    },
    {
      "position": "bottom",
      "safeArea": "false",
      "shadow": "true",
      "label": "贴底吸顶(合计条)"
    }
  ],
  "anatomy": {
    "sticky": "根元素,就是滚动内容流里的那一行(sticky 不脱离文档流,因此不需要占位元素)",
    "title": "标题文字,单行省略,占满剩余宽度",
    "extra": "右侧附加说明(数量、合计等),可选",
    "action": "右侧动作按钮(原生 button,热区 44px),可选"
  },
  "structurePatterns": {
    "position": "top 贴顶(最常用)/ bottom 贴底(合计条一类)",
    "safeArea": "false 只用偏移量 / true 再叠加刘海或底部横条安全区",
    "shadow": "false 恒定无投影 / true 仅吸顶后才有投影",
    "状态类": "is-stuck 已贴合边缘(由宿主按滚动位置切换)"
  },
  "usageHints": [
    "让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)",
    "用 CSS 原生 position: sticky —— 触屏惯性滚动下 JS 的 fixed 方案会抖动,且脱离文档流后要补占位元素",
    "贴合位置由组件级变量 --kole-m-sticky-offset 决定,默认等于导航栏高度",
    "组件本身不监听滚动:is-stuck 由宿主按滚动位置切换",
    "吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致"
  ],
  "doNotInvent": [
    "吸顶触发的位移/缩放动画(规格只定义了贴合,没有定义形态演变)",
    "多个吸顶条的层叠顺序与相互推挤(层叠上下文规则由宿主决定)",
    "进入/离开视口时的埋点事件与曝光统计",
    "拖拽排序与吸附"
  ],
  "unknowns": [
    "吸顶判定是否应由组件内部提供一个可选的滚动监听辅助(当前完全交给宿主)",
    "是否需要在吸顶时自动隐藏相邻内容(当前不做,靠宿主布局)",
    "贴底形态在内容不足一屏时是否应始终贴底"
  ],
  "interaction": [
    "贴合位置由组件级变量 --kole-m-sticky-offset 决定,业务侧覆盖它即可适配自有导航",
    "safeArea=true 时偏移叠加 --kole-m-safe-top / --kole-m-safe-bottom;env() 不可用时为 0px",
    "组件本身不监听滚动:is-stuck 由宿主按滚动位置切换",
    "触屏热区:整条高度 ≥ 44px;右侧动作按钮自身撑满 44px",
    "position=bottom 时吸顶后的描边换到上边"
  ],
  "accessibility": [
    "根是普通容器,标题文字正常参与读屏朗读;is-stuck 是纯视觉增强,不添加任何 aria-*",
    "右侧动作是原生 button,名称由可见文字承担;装饰性图形必须 aria-hidden=\"true\"",
    "吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致",
    "prefers-reduced-motion 下不引入任何过渡"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "position",
        "type": "'top' | 'bottom'",
        "default": "'top'",
        "desc": "变体 position:贴顶还是贴底(规格 §39.3)",
        "required": false
      },
      {
        "name": "safeArea",
        "type": "boolean",
        "default": "false",
        "desc": "变体 safeArea:偏移叠加刘海 / 底部横条安全区(规格 §39.3)",
        "required": false
      },
      {
        "name": "shadow",
        "type": "boolean",
        "default": "false",
        "desc": "变体 shadow:仅吸顶后才有投影(规格 §39.3)",
        "required": false
      },
      {
        "name": "stuck",
        "type": "boolean",
        "default": "false",
        "desc": "状态 stuck:是否已贴合边缘,由宿主按滚动位置传入(规格 §39.4)",
        "required": false
      },
      {
        "name": "title",
        "type": "string",
        "default": "''",
        "desc": "标题文字,单行省略(规格 §39.2 title)",
        "required": false
      },
      {
        "name": "actionText",
        "type": "string",
        "default": "''",
        "desc": "右侧动作按钮文字;为空时不渲染按钮(规格 §39.2 action)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "action",
        "params": "—",
        "desc": "点击右侧动作按钮时触发(规格 §39.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "额外的栏内内容(放在标题与动作之间)"
      }
    ]
  },
  "variantClasses": {
    "position": {
      "top": [
        ".kole-m-sticky--top"
      ],
      "bottom": [
        ".kole-m-sticky--bottom"
      ]
    },
    "safeArea": {
      "false": [],
      "true": [
        ".kole-m-sticky--safe"
      ]
    },
    "shadow": {
      "false": [],
      "true": [
        ".kole-m-sticky--shadow"
      ]
    }
  },
  "demos": [
    {
      "id": "basic",
      "group": "01 组件类型",
      "title": "基础吸顶",
      "desc": "position: sticky 的默认形态:在框内向下滚动时标题条贴住框顶,不脱离文档流因此无需补占位元素。",
      "variant": "position=top"
    },
    {
      "id": "shadow",
      "group": "01 组件类型",
      "title": "吸顶后出阴影",
      "desc": "shadow=true:未吸顶时与内容齐平,贴合后才出现投影,用形态而不是颜色表达「已经贴住了」。",
      "variant": "shadow=true"
    },
    {
      "id": "safe-area",
      "group": "01 组件类型",
      "title": "安全区叠加",
      "desc": "safeArea=true:偏移量再叠加刘海高度;无刘海设备上安全区为 0px,表现与不带 safe 一致。",
      "variant": "safeArea=true"
    },
    {
      "id": "bottom",
      "group": "02 组件状态",
      "title": "贴底吸顶",
      "desc": "position=bottom:内容不足一屏时贴住容器底,常用于订单合计条;吸顶后描边换到上边。",
      "variant": "position=bottom"
    },
    {
      "id": "action",
      "group": "02 组件状态",
      "title": "带动作",
      "desc": "右侧动作是原生 button、热区 44px;点击回传 action,具体做什么由宿主决定。",
      "variant": "actionText 非空"
    }
  ],
  "related": [
    {
      "slug": "navbar",
      "why": "页面级固定导航用导航栏(fixed 且带安全区与返回),区块内的贴合才用吸顶容器"
    },
    {
      "slug": "cell",
      "why": "吸顶条下面承载的列表项用单元格,吸顶容器只负责那一行的贴合行为"
    },
    {
      "slug": "mobile-list",
      "why": "需要分组标题列表时用列表组件,而不是给每一行都套一个吸顶容器"
    }
  ]
}