吸顶容器Sticky
让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)
导航 规格 39 · 吸顶容器 Sticky 6 端实现 触摸优先
<!-- ① 令牌: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: sticky 的默认形态:在框内向下滚动时标题条贴住框顶,不脱离文档流因此无需补占位元素。
查看代码(演示页原文 · 19 行)
<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:未吸顶时与内容齐平,贴合后才出现投影,用形态而不是颜色表达「已经贴住了」。
查看代码(演示页原文 · 21 行)
<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:偏移量再叠加刘海高度;无刘海设备上安全区为 0px,表现与不带 safe 一致。
查看代码(演示页原文 · 14 行)
<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:内容不足一屏时贴住容器底,常用于订单合计条;吸顶后描边换到上边。
查看代码(演示页原文 · 13 行)
<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>右侧动作是原生 button、热区 44px;点击回传 action,具体做什么由宿主决定。
查看代码(演示页原文 · 15 行)
<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 |
safeArea | boolean | false | 变体 safeArea:偏移叠加刘海 / 底部横条安全区(规格 §39.3) | N |
shadow | boolean | false | 变体 shadow:仅吸顶后才有投影(规格 §39.3) | N |
stuck | boolean | false | 状态 stuck:是否已贴合边缘,由宿主按滚动位置传入(规格 §39.4) | N |
title | string | '' | 标题文字,单行省略(规格 §39.2 title) | N |
actionText | string | '' | 右侧动作按钮文字;为空时不渲染按钮(规格 §39.2 action) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
action | — | 点击右侧动作按钮时触发(规格 §39.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 额外的栏内内容(放在标题与动作之间) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-sticky-offset | var(--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 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
position | top / bottom | top .kole-m-sticky--top bottom .kole-m-sticky--bottom |
safeArea | false / true | false (由数据驱动,无专属类) true .kole-m-sticky--safe |
shadow | false / 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 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Sticky.css · 纯样式(CSS) · 97 行
/* 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 行
<!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 行
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 行
<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 行
<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 行
<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": "需要分组标题列表时用列表组件,而不是给每一行都套一个吸顶容器"
}
]
}