网站 UI 实现:布局、层叠与交互状态
原始资料:Practical Typography、Josh W. Comeau 的布局、Flexbox、层叠、阴影、按钮文章,以及 Rauno Freiberg 的 Invisible Details of Interaction Design。
AI 参与说明(Agent:Codex):本文由 Codex 阅读上述免费公开资料与相关 Web 标准后整理,并编写原创案例、HTML/CSS 示例和验收步骤。来源观点、规范要求与本文建议在相关段落分别标明;未使用付费书籍或课程正文。整理日期为 2026-10-22,执行入口为 Codex Desktop;模型完整标识与 reasoning effort 未取得任务级运行记录。
内容扩充(2026-10-22,Agent:Codex):补充字体与混排、整页排版案例、Flex 尺寸推导、裁切与浮层、深色表面、保存状态及手势与导航的异常路径。新增数值、产品场景与代码练习为本文原创综合,并分别补充一手依据与实际核验记录。执行入口 Codex Desktop;模型完整标识与 reasoning effort 未取得任务级运行记录。
结构整理(2026-10-23,Agent:Codex):本文收敛为网站 UI 的实现专题,保留代码、尺寸推导、故障反例和 2026-10-22 的实测记录;设计方法与资料选读统一见关联主文。本次只做结构与文字编辑,未改动代码,也未重新执行历史实验。执行入口 Codex Desktop;模型完整标识与 reasoning effort 未取得任务级运行记录。
目录调整(2026-10-28,Agent:Cursor):本文迁入
docs/Frontend/UI/,slug 不变。canonical 路径改为/docs/Frontend/UI/ui-design-practice-for-engineers/,旧路径/docs/Frontend/ui-design-practice-for-engineers/写入aliases。模型grok-4.7-high-fast,提供方 xAI,执行入口 Cursor Cloud。reasoning effort 未作为独立字段取得运行记录。
本篇把一张虚构任务详情页拆成可复现的实现实验:正文与费用表、Flex 尺寸分配、长文件名、浮层遮挡、表面深度,以及保存状态。每组给出前置条件、代码、输入变更与可观察结果。尺寸、配色和时长用于实验,需要随实际内容与运行环境调整。
内容结构、视觉层级、交互取舍与交付流程先看网站 UI 设计主文。需要排查尺寸、溢出、遮挡或状态错误时,再按下表进入本篇。
| 实现问题 | 对应实验 |
|---|---|
| 字体、行高与金额格式怎样落到页面 | 第 1–2 节:正文、费用表与完整任务页 |
| 声明的宽度为什么没有成为最终宽度 | 第 3 节:布局模式、Flex 推导与长文件名 |
| 为什么调大层级仍被遮挡或裁切 | 第 4 节:Stacking context、裁切与 Top layer |
| 阴影、边界与焦点分别改变什么 | 第 5–6 节:表面参数、按钮与保存状态 |
| 怎样检查旧回调、取消和返回恢复 | 第 7–8 节:实现边界、输入检查与历史实测 |
先认识几个术语
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Typography | 文字排版 | 安排字体、字号、行距与页面中的文字关系 |
| Layout mode | 布局模式 | 浏览器计算元素尺寸和位置所依据的一组规则 |
| Flow | 正常文档流 | 块级内容与行内文字按正常阅读顺序排布 |
| Flexbox | 弹性盒布局 | 沿主轴分配空间,协调项目的增长、收缩和对齐 |
| Grid | 网格布局 | 用共享的行列轨道组织项目位置与对齐关系 |
| Stacking context | 层叠上下文 | 将一组元素的前后层级作为一个整体参与外部比较 |
| Top layer | 顶层 | 浏览器维护的绘制层,供打开的 Popover 等元素显示在普通内容之上 |
| Focus indicator | 焦点指示 | 告诉用户当前哪个控件将接收键盘操作的可见提示 |
| Direct manipulation | 直接操控 | 拖动等输入发生时,界面对象立即随输入变化 |
1. 正文尺度与字体实验
正文的设计选择见主文的 Typography 方法。本节只检查这些选择怎样受字体、长度单位、回退与换行影响。
固定文字与字体,单独改变行高
下面保持文字和字号相同,只改变行高;1.65 是中文练习参数。Butterick 给出的每行平均 45–90 个字符、行高约为字号 1.2–1.45 倍是西文排版建议,不能直接当作中文标准。Line length、Line spacing、Point size
对照 A · 16px / 1.2
本任务需要整理页面中的附件与费用。提交前,请核对文件名称、处理状态和预计完成时间。包含 API 名称与数字 128 的说明,也应保持清楚的阅读节奏。
对照 B · 16px / 1.65
本任务需要整理页面中的附件与费用。提交前,请核对文件名称、处理状态和预计完成时间。包含 API 名称与数字 128 的说明,也应保持清楚的阅读节奏。
把下面这段无依赖 HTML 保存为文件即可查看。段落使用 <p>,标题与段落关系通过间距表达;本例只提供实验起点。Body text、Space between paragraphs
<article class="task-copy" lang="zh-CN">
<h2>提交任务前</h2>
<p>请核对文件名称、处理状态和预计完成时间。</p>
<p>附件可以包含中文、API 名称和数字,例如 build-128。</p>
</article>
<style>
.task-copy {
box-sizing: border-box;
max-width: 36rem;
margin-inline: auto;
padding: 1rem;
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.65;
}
.task-copy p { margin: 0; }
.task-copy p + p { margin-block-start: 0.8em; }
.task-copy h2 {
margin: 1.5em 0 0.5em;
font-size: 1.35em;
line-height: 1.3;
}
</style>
英文正文也可以从 max-width: 65ch 开始试。ch 按字体中数字 0 的前进宽度计算,不等于准确字符数或汉字数;换字体后需要重新观察行长。CSS 长度单位定义
输入检查:固定字体,分别改变宽度与行高,再加入中文长句、英文长词、链接与行内代码。页面放大后仍应能换行,不能仅凭一次截图宣称某个参数最好。
字体、字重与字距的运行条件
完整任务页采用 system-ui, sans-serif 承担正文和控件,ui-monospace, monospace 显示文件名,接受各系统字形不同的取舍。正文/UI 与代码是本例的两个角色,不是字体数量上限。Mixing fonts、System fonts
| 实现条件 | 对本篇代码的影响 | 检查输入 |
|---|---|---|
| 浏览器按字符选择字体,缺字会继续回退 | 一个 font-family 声明不保证整段由同一字体绘制;列表末尾保留通用字体族 | 中文、英文、标点与 I/l/1、O/0 一起比较 |
| 字重数值按字体可用字形匹配 | 600 不是跨字体统一的视觉粗细,也不保证存在独立的 600 字形 | 比较正文 400 与标题 600 的实际字形 |
| 网络字体可能替换回退字体 | font-display 控制等待与替换策略,不保证两套字体字宽相同 | 慢网络或阻止字体请求,检查标题、按钮和金额 |
letter-spacing 调整整体字符间距 | 不能把短英文全大写的参数机械应用到中文正文;本例正文保持 normal | 同时放入短标签、长标题、中文与代码 |
font-kerning 使用字体中的字偶距信息 | normal 不会生成字体原本没有的配对,也不等于统一字距 | 比较拉丁文字配对,不只看声明是否存在 |
依据:font-family、font-weight、font-display、letter-spacing、font-kerning。
下面只对独立英文标签试用 0.08em;需要放进第 2 节任务页的样式范围,不在全局修改中文与代码。
/* 本文实验参数:分别控制角色,不放在全局 body 上。 */
.task-sheet { letter-spacing: normal; }
.task-sheet .eyebrow[lang="en"] { letter-spacing: 0.08em; }
.task-sheet .latin-title { font-kerning: normal; }
中英混排与可复制内容
自然语言的弯引号、连字符和破折号可以按文体处理;命令、URL、文件名与 JSON 必须保留机器读取所需的原始字符,不自动替换引号或插入装饰空格。Straight and curly quotes、Hyphens and dashes
本例用 128 MB 将短数值与单位连在一起;不换行空格不是对齐工具,也不应把整段说明绑成一行。字段对齐交给布局,中文括号、引号与句末标点还要检查实际断行。W3C《中文排版需求》为这些问题提供排查入口;该资料在初版核验时为草案,不将其书籍版式数值当作网页统一规范。Nonbreaking spaces、中文排版需求
输入检查:字体加载前后与页面放大后各看一次。复制结果保持原值,短单位没有孤立到下一行;若大片空白或溢出由不换行规则造成,就缩小其应用范围。
2. 费用表与完整任务页
分组、层级和密度的设计方法见主文。这里把已选参数放进语义 HTML 与 CSS,再用长内容和不同密度检查约束。示例金额、单位与状态均为虚构数据。
费用表:格式、字形与对齐分别处理
下面的表格统一使用人民币元、两位小数与右对齐;表头、caption 和行标题表达数据关系。保存为 HTML 文件即可查看。
<table class="costs">
<caption>预计费用,单位:人民币元</caption>
<thead><tr><th scope="col">项目</th><th scope="col">金额</th></tr></thead>
<tbody>
<tr><th scope="row">文件处理</th><td>9.90</td></tr>
<tr><th scope="row">资料整理</th><td>1,000.00</td></tr>
<tr><th scope="row">导出</th><td>12.00</td></tr>
</tbody>
</table>
<style>
.costs { width: 100%; border-collapse: collapse; }
.costs caption { text-align: start; margin-block-end: 0.75rem; }
.costs th, .costs td {
padding: 0.75rem 1rem;
border-block-end: 1px solid #d7dde0;
text-align: start;
}
.costs tbody th { font-weight: 400; }
.costs th:last-child, .costs td:last-child { text-align: end; }
.costs td { font-variant-numeric: tabular-nums; }
</style>
tabular-nums 仅在字体提供对应字形时生效;它请求数字字形特性,不会把整套字体变成等宽字体,也不负责补小数位或自动对齐小数点。此例用固定精度与右对齐配合;若各行必须保留不同精度,应另行安排整数与小数部分。MDN:font-variant-numeric
格式化前要确认列语义:币种、单位与精度一致才适合直接比较;编号 00128 保留前导零,缺失或待估算不改成 0.00。金额、百分比、标识符与文件大小分别决定展示规则。Grids of numbers
把排版参数落成一张任务页
下表是这张中文任务详情页的实验参数,不是从原书提取的设计系统。各角色的字号、行高与间距分别设置,供后面的 HTML/CSS 使用。
| 角色或关系 | 本例参数 | 修改时先观察什么 |
|---|---|---|
| 页面范围 | 最大宽度 52rem,内边距 1.5rem | 是否仍有足够空间显示费用列 |
| 说明文字 | 最大宽度 36rem,1rem / 1.65,字重 400 | 连续阅读与中英混排的换行 |
| 任务标题 | 1.5rem / 1.3,字重 600 | 长标题换行后是否仍与状态分组 |
| 分区标题 | 1.125rem / 1.4,字重 600 | 能否据此定位“任务说明”“附件”“预计费用” |
| 字段标签 | 0.875rem / 1.5,正常字重 | 减弱后是否仍容易读清 |
| 段落之间 | 0.8em | 段落能区分,又不显得彼此无关 |
| 标题到内容 | 0.5rem | 标题更靠近它引出的内容 |
| 大分区之间 | 2rem | 内容变多后还能认出新的分区 |
| 表格单元格 | 上下 0.75rem、左右 1rem | 文字不拥挤,金额沿列便于比较 |
下面 HTML 使用标题、分区、表格与按钮表达内容;紧随其后的 CSS 负责文字角色与距离,两段合并到一个 HTML 文件中运行。保存按钮只显示任务页结构,没有提交行为。已有项目应把 CSS 放进相应组件的样式范围。
<article class="task-sheet" lang="zh-CN">
<header>
<h1>整理季度附件</h1>
<p class="task-meta">任务 00128 · 状态:待审核</p>
</header>
<section class="task-section" aria-labelledby="task-description">
<h2 id="task-description">任务说明</h2>
<div class="task-prose">
<p>请核对文件名称和处理范围,再提交本次任务。</p>
<p>本次附件为 128 MB,包含 API 说明与 build-128 记录。</p>
</div>
</section>
<section class="task-section" aria-labelledby="task-attachment">
<h2 id="task-attachment">附件</h2>
<p><code>quarterly-build-128.pdf</code></p>
<p class="task-meta">上传完成 · 1 个文件</p>
</section>
<section class="task-section" aria-labelledby="task-costs">
<h2 id="task-costs">预计费用</h2>
<table>
<caption>单位:人民币元</caption>
<thead><tr><th scope="col">项目</th><th scope="col">金额</th></tr></thead>
<tbody>
<tr><th scope="row">文件处理</th><td>9.90</td></tr>
<tr><th scope="row">资料整理</th><td>1,000.00</td></tr>
<tr><th scope="row">导出</th><td>12.00</td></tr>
</tbody>
<tfoot><tr><th scope="row">合计</th><td>1,021.90</td></tr></tfoot>
</table>
</section>
<footer class="task-section">
<p class="task-meta">保存仅记录当前修改,任务仍需另行提交。</p>
<button type="button">保存修改</button>
</footer>
</article>
.task-sheet {
box-sizing: border-box;
max-inline-size: 52rem;
margin-inline: auto;
padding: 1.5rem;
font-family: system-ui, sans-serif;
font-size: 1rem;
font-weight: 400;
line-height: 1.65;
letter-spacing: normal;
color: #172b35;
background: white;
}
.task-sheet :where(h1, h2, p) { margin: 0; }
.task-sheet h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
}
.task-sheet h2 {
margin-block-end: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
}
.task-sheet .task-section { margin-block-start: 2rem; }
.task-sheet .task-prose { max-inline-size: 36rem; }
.task-sheet .task-prose p + p { margin-block-start: 0.8em; }
.task-sheet .task-meta {
margin-block-start: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
color: #485d68;
}
.task-sheet code {
font-family: ui-monospace, monospace;
font-size: 0.95em;
overflow-wrap: anywhere;
}
.task-sheet table { inline-size: 100%; border-collapse: collapse; }
.task-sheet caption { text-align: start; margin-block-end: 0.75rem; }
.task-sheet :where(th, td) {
padding: 0.75rem 1rem;
border-block-end: 1px solid #d7dde0;
text-align: start;
}
.task-sheet tbody th { font-weight: 400; }
.task-sheet :where(th, td):last-child { text-align: end; }
.task-sheet td { font-variant-numeric: tabular-nums; }
.task-sheet tfoot { font-weight: 600; }
.task-sheet :where(thead, tfoot) th { font-weight: 600; }
.task-sheet[data-density="compact"] :where(th, td) {
padding-block: 0.5rem;
}
.task-sheet button {
margin-block-start: 1rem;
padding: 0.625rem 1rem;
font: inherit;
}
这里使用无单位的 line-height,各角色根据自己的字号计算行高。正文为 1rem / 1.65,标签为 0.875rem / 1.5,标签不会继承固定像素的行距;rem 随根字号变化,1rem 不永久等于 16px。MDN:line-height、CSS 长度单位定义
输入检查:将标题加长一倍、插入中文长说明与多行备注,再给 .task-sheet 添加 data-density="compact"。紧凑模式只把表格上下内边距由 0.75rem 改为 0.5rem,不同时缩小全文。多行内容应撑高行,不能被固定高度裁掉;若加入复选框或操作按钮,另查命中范围。Tables
观察完整名称、费用单位与合计是否保留,字体回退和页面放大后是否仍可读。浅色配色还需随实际主题检查;这张结构页没有实现真实保存,状态转换见第 6 节。
3. 先看 Layout mode,再决定谁可以收缩
Josh 的布局文章把注意力从“这个属性是什么意思”转到“当前布局怎样使用这个属性”。同一个尺寸声明参与不同的 Layout mode,最终结果可能不同。文章使用的 Layout algorithm 是解释用语;对应 CSS 中的布局模式,不是额外存在的一套 API。Understanding Layout Algorithms
先判断需要哪一种关系
选择布局时,先写出内容之间的约束。例如“按钮按文字宽度排列,放不下就换行”和“上下两行的金额列必须对齐”,虽然都能画成几个盒子,却需要不同的空间关系。
| 任务页中的关系 | 从哪种布局开始 | 具体操作与选择理由 |
|---|---|---|
| 标题后跟正文,正文随内容增高 | Flow | 保留正常文档流,用段间距组织内容;不必为了上下排列给每层都加 Flexbox |
| 附件图标、文件名、操作按钮沿一行协调 | Flexbox | 在共同父级建立 Flexbox,声明哪些项目可收缩;换行后的每一行独立分配空间 |
| 多张信息卡在行与列上共享对齐线 | Grid | 在父级定义列轨道,项目进入同一套网格;不用给每一行单独计算宽度 |
Flow 中的块级与行内排布、Flexbox 的单轴分配,以及 Grid 的行列轨道是不同的组织机制;嵌套使用很常见。给外层设置 display: grid,并不意味着内部正文也必须逐段变成网格。MDN:正常文档流、Grid 与其他布局方式
下面的卡片共享两列;卡片内部的标题和正文仍按 Flow 排列,操作区单独用 Flexbox。它是页面结构练习,42rem 是本例收为一列的试验点。
<section class="task-grid" aria-label="任务概况">
<article>
<h2>待整理附件</h2>
<p>四个文件需要补全名称与处理状态。</p>
<div class="task-actions">
<button type="button">查看附件</button>
<button type="button">下载清单</button>
</div>
</article>
<article>
<h2>费用核对</h2>
<p>确认计费项目后,再提交本次修改。</p>
</article>
</section>
<style>
.task-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.task-grid > article { min-width: 0; }
.task-grid h2 { margin: 0 0 0.5em; }
.task-grid p { margin: 0 0 1em; line-height: 1.6; }
.task-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 42rem) {
.task-grid { grid-template-columns: minmax(0, 1fr); }
}
</style>
输入变更预测:增加第三张卡片,它进入下一行并沿用列宽;将第一张卡片的操作标签改长,按钮在自己的卡片内换行,不挤压相邻卡片。若需求变成“每一排按钮都要跨卡片对齐”,当前内层 Flexbox 就没有表达这条跨行约束,应重新设计共享轨道,而不是不断给按钮写固定宽度。单看两个盒子横排的截图,不能判断选型是否合适。
一个能量出来的尺寸例子
下面两个盒子都声明宽度 200px,父容器宽度为 300px。在没有边框、内边距和间隙的本例中,两个 Flex 项目默认参与收缩,最终各约 150px。
<div class="pair"><div>A</div><div>B</div></div>
<style>
.pair { display: flex; width: 300px; }
.pair > div {
width: 200px;
min-width: 0;
height: 40px;
background: #dce9ed;
}
</style>
width 是计算输入,不能直接当作最终显示宽度。Flex 项目还受基础尺寸、增长/收缩系数和最小/最大尺寸约束;收缩量也不是永远按项目数平分。上例两项的基础尺寸相同,才得到近似相同的结果。An Interactive Guide to Flexbox、MDN:flex-shrink
验收:在开发者工具中量两个盒子的边界;去掉父级的 display: flex 后,观察它们改为纵向排列、各为 200px。如果产品要求维持原宽度,应明确选择禁止收缩、换行或滚动,不要靠 !important 反复覆盖尺寸。
把基础尺寸、剩余空间和限制条件分开计算
对本节的横向单行例子,可以按以下顺序诊断。先读项目的 flex-basis;为 auto 时再看横向尺寸与内容。接着算容器内部空间减去项目基础尺寸和间隙后的余额,判断应增长还是收缩;最后检查最小、最大尺寸是否阻止了分配。明确的非 auto 基础尺寸会优先参与弹性分配,不能看到 width 就直接拿它当基数。MDN:flex-basis
下面使用不同的基础尺寸,排除边框、内边距、自动最小尺寸与换行的干扰。所有数值都是本文为计算练习设置的。
<div class="sizing-lab">
<div class="sizing-a">A</div>
<div class="sizing-b">B</div>
</div>
<style>
.sizing-lab { display: flex; width: 340px; gap: 20px; }
.sizing-a, .sizing-b {
min-width: 0;
height: 40px;
line-height: 40px;
outline: 1px solid #426273;
background: #e4edf1;
}
.sizing-a { flex: 0 1 240px; }
.sizing-b { flex: 0 1 160px; }
</style>
两项共有 240 + 160 = 400px 的基础尺寸,间隙占 20px,容器只给 340px,所以缺口是 80px。本例的收缩权重为“收缩系数 × 基础尺寸”:A 为 1 × 240,B 为 1 × 160。A 分担 80 × 240 / 400 = 48px,B 分担 32px,得到 192px 和 128px;两项加间隙恰好为 340px。这解释了为什么相同的 flex-shrink: 1 不代表减去相同像素。MDN:flex-shrink
先预测,再逐项修改样式并量测。以下各行都从上面的原始代码单独出发,不能把多行改动叠加后仍照抄结果。
| 单独改变的输入 | 计算过程 | 应观察到的尺寸 |
|---|---|---|
将 B 改为 flex: 0 3 160px | 权重变为 240 : 480,A 分担约 26.67px,B 分担约 53.33px | A 约 213.33px,B 约 106.67px |
给 A 增加 min-width: 220px | 初次结果中的 A 小于下限,固定到 220px 后重新分配剩余空间 | A 220px,B 100px |
给 A、B 都增加 min-width: 180px | 两项下限加间隙为 380px,已经超过容器 | 不能全部塞进 340px,继续增大收缩系数也无济于事 |
将容器改为 520px,两项改为 flex: 1 1 240px 与 flex: 1 1 160px | 除去间隙后有 500px,比基础尺寸多 100px,各增长 50px | A 290px,B 210px;相同增长系数不保证最终等宽 |
同样使用 520px,两项都改为 flex: 1 1 0 | 两个零基础尺寸平分可用的 500px | 本例各 250px |
上面的下限案例需要重新分配,不能先按比例算一次,再把超出限制的结果随手截断就结束。规范会处理最小/最大尺寸限制、冻结已受限项目,再继续分配;这里只使用明确基数与大于等于 1 的系数演算,没有替代完整算法。CSS Flexbox:弹性长度分配
验收:用布局面板或盒子边界核对结果,允许亚像素舍入。出现明显偏差时,依次检查实际容器内宽、gap、边框与内边距、基础尺寸、最小/最大尺寸,再检查项目是否已换到不同的行。不要在尚未确认缺口来源时同时修改五个参数。
附件行:给图标留固定位置,让文件名退让
任务页里更常见的是附件名把整行撑开。Flex 项目的自动最小尺寸可能受内容限制,所以只增加收缩系数未必能解决长字符串问题。MDN:min-width
<div class="attachment">
<span class="attachment-icon" aria-hidden="true">↳</span>
<p class="attachment-name">
b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8.pdf
</p>
</div>
<style>
.attachment { display: flex; align-items: start; gap: 12px; }
.attachment-icon { flex: 0 0 24px; }
.attachment-name {
flex: 1 1 auto;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
</style>
这里分别表达容器退让与文本断行策略:min-width: 0 允许文字盒子继续缩小,overflow-wrap: anywhere 允许很长的不可断字符串换行。anywhere 引入的断点也会参与最小内容尺寸计算,因此不是所有场景都必须同时添加这两项。若改用省略号,还要提供查看完整名称的入口,不能让重要差异永久消失。MDN:overflow-wrap
验收:换成一个长文件名、一个很短的文件名,再逐渐缩窄容器。图标位置稳定,长字符串在自己的区域换行,页面不因这一行横向溢出。操作按钮较多时允许工具栏换行,不要把所有项目都设为禁止收缩。
选择换行、省略还是滚动,要看哪些信息必须保留
min-width: 0 放宽盒子的下限,不会替你决定长文件名怎样呈现。若只加这一条而保留不换行的内容,盒子可能已缩小,文字仍画到邻居上面。反过来,把 overflow: hidden 加到整行虽可能让溢出消失,也会把按钮、焦点轮廓或需要阅读的内容一起藏掉。
先按任务选择策略,再设置对应的样式:
| 信息与操作 | 本文建议 | 应接受的代价与验证重点 |
|---|---|---|
| 详情页中需要辨认完整附件名 | 允许换行,保留 overflow-wrap: anywhere | 行高随名称增长;检查图标与文字顶部对齐,以及后续操作仍能找到 |
| 密集列表先扫读,完整名称可以展开查看 | 限制为单行并显示省略号 | 可能隐藏尾部扩展名或区别字符;必须有可用键盘打开的完整名称入口 |
| 日志或代码需要保持原有行结构 | 在明确的内容区域提供横向滚动 | 用户要主动横移;不能把整个页面变成意外的横向滚动区 |
anywhere 与 break-word 也有差别:前者新增的断点参与最小内容尺寸计算,后者不参与。若把上例改为 break-word,保留显式的 min-width: 0 有助于把“允许盒子缩小”和“内容随后断行”分开表达;white-space: nowrap 又会禁止软换行,不能指望再加一个断词属性抵消它。MDN:overflow-wrap
下面是一套单行省略与完整名称展开的独立例子,保存为 HTML 即可使用。text-overflow 只规定截断后的提示,并不自动制造受限宽度或禁止换行,所以示例同时设置了这些条件。MDN:text-overflow
<section class="file-sample" aria-label="附件名称">
<div class="file-row">
<span class="file-icon" aria-hidden="true">↳</span>
<span class="file-short">taskarchive20261022revision018completeattachmentmanifest.pdf</span>
</div>
<details>
<summary>查看完整文件名</summary>
<p class="file-full">taskarchive20261022revision018completeattachmentmanifest.pdf</p>
</details>
</section>
<style>
.file-sample { width: min(100%, 22rem); }
.file-row { display: flex; gap: 12px; }
.file-icon { flex: 0 0 24px; }
.file-short {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-sample details { margin-block-start: 8px; }
.file-full { margin: 8px 0 0; overflow-wrap: anywhere; }
</style>
输入变更预测:只删除 text-overflow: ellipsis,内容仍被截断,只是没有省略号;只删除 white-space: nowrap,当前连续字符串仍可能不换行,换成带空格的名称后才容易看出区别。把两条名称中的版本号分别改为 018 和 019,若列表隐藏了关键差异,先调整信息展示,而不是仅把容器再压窄。用 Tab 找到“查看完整文件名”,按 Enter 展开后应能读到与当前附件一致的全文。
4. 用 Stacking context 解释遮挡,停止堆大数字
页面中的所有元素没有一张统一的全局层级榜。一个元素的层级需要放在它所属的 Stacking context 中理解;子元素无法仅靠很大的 z-index 脱离祖先参与外部比较。What The Heck, z-index??
任务页里的菜单即使写成 z-index: 999,仍可能被页头挡住:
<header class="page-bar">页头:层级 2</header>
<main class="task-content">
<div class="task-menu">菜单:层级 999</div>
</main>
<style>
.page-bar {
position: relative;
z-index: 2;
height: 40px;
background: #d8dfdf;
}
.task-content { position: relative; z-index: 1; }
.task-menu {
position: absolute;
top: -20px;
z-index: 999;
background: #f5d59d;
}
</style>
应比较的是页头的组 2 与内容的组 1;菜单的 999 位于后者内部。在这个完整例子中,将 .task-content 的 z-index 改为 auto 可以解除这一层分组,但真实页面还要沿祖先检查其他创建条件,例如 transform 或小于 1 的 opacity。MDN:Stacking context
实际排查时,先判断菜单是被覆盖,还是被祖先的 overflow 裁切;两者不是同一个问题。随后确认哪个祖先创建了 Stacking context,再决定是移除无意的分组、调整容器层级,还是将跨容器浮层交给统一的浮层入口。isolation: isolate 适合主动限制组件内部层级,但它不会把整个组件自动抬到前面,也可能限制内部浮层的跨组显示。
验收:在上例菜单与页头重叠的区域检查命中元素;单独增大 999,结果不应改变。再按上述条件移除内容组,确认菜单出现在前面。回到实际页面后,同时检查焦点、关闭与滚动行为,不能把“终于盖住了页头”当作浮层全部验收通过。
把覆盖、裁切和浏览器顶层分成三条路径
任务菜单消失时,先记下它的边界和消失位置,再对一个条件做修改。以下三种现象需要不同的修正,不应都归结为层级不够高。
| 观察到的现象 | 先验证什么 | 通过后怎样修正 |
|---|---|---|
| 菜单完整地位于另一个表面后面 | 检查双方最近的层叠上下文,沿祖先找首次分叉的组 | 移除无意的组、调整需要比较的外层组,或把跨容器浮层放到合适的挂载位置 |
| 菜单沿某个祖先边缘被整齐截断 | 暂时关闭该祖先的裁切,看缺失部分是否恢复 | 让真正需要裁切的内容留在内层,浮层放在外层;或使用适合该交互的顶层机制 |
| 普通页面元素无论多大数值都盖不住打开的原生浮层 | 检查对方是否进入 Top layer | 按浮层的打开、关闭与模态语义处理,不继续竞争普通 z-index |
overflow: hidden 会裁切溢出内容,但它本身不是创建层叠上下文的通用开关;isolation: isolate 创建层叠上下文,却不负责裁切。两种机制可以同时存在。改变其中一个后仍不正常,应继续检查另一条路径。MDN:overflow、isolation
下面把普通绝对定位元素与原生 Popover 放在同一个裁切容器内。它不依赖脚本;请在支持 Popover 的浏览器中保存为 HTML 操作。原生浮层在打开后才进入 Top layer,单独添加属性不会让隐藏元素自动显示。MDN:popover
<div class="crop-zone">
<p>有裁切边界的任务卡片</p>
<button type="button" popovertarget="task-floating-note">打开原生说明</button>
<div class="cropped-menu">普通元素:右侧与底部被裁掉</div>
<div id="task-floating-note" popover>
<p>这段说明进入浏览器顶层,不受外面卡片的 overflow 裁切。</p>
<button type="button" popovertarget="task-floating-note"
popovertargetaction="hide">关闭说明</button>
</div>
</div>
<style>
.crop-zone {
position: relative;
width: 260px;
height: 100px;
padding: 12px;
box-sizing: border-box;
overflow: hidden;
border: 1px solid #426273;
}
.crop-zone > p { margin: 0 0 8px; }
.cropped-menu {
position: absolute;
left: 12px;
top: 80px;
width: 280px;
padding: 8px;
z-index: 9999;
background: #f5d59d;
}
#task-floating-note {
max-width: min(28rem, calc(100vw - 2rem));
box-sizing: border-box;
padding: 1rem;
}
</style>
输入变更预测:把 9999 改得更大,普通元素仍被裁切;只把容器的 overflow 改为 visible,它的缺失部分恢复。还原裁切后打开说明,Popover 仍能出现在卡片外。这个说明面板没有实现菜单的方向键导航,所以不要仅因它浮在上面就加 role="menu"。
Top layer 是浏览器维护的绘制层,位于当前文档的普通内容之上;position: fixed、把节点搬到 body 或把 z-index 写得极大,都不等于进入它。打开的 Popover 与通过 showModal() 显示的模态 <dialog> 是常见入口;仅写 <dialog open> 表示非模态显示,不能用来证明已进入同一机制。MDN:Top layer、dialog
选择入口还要服从交互要求。补充说明面板通常不需要锁住背景操作,Popover 可以从这里开始;必须让用户先完成或取消的确认流程,才评估模态对话框。浏览器顶层解决的是绘制与相关平台行为,仍需决定触发器、位置、可访问名称、关闭方式与焦点去向,不能把“进入顶层”当作完整菜单或对话框实现。
isolation 应用于内部组合,不作为浮层修复开关
isolation: isolate 适合一个内部有多层装饰的预览卡:装饰、正文、徽标只需彼此排序,不应和相邻卡片的内部层级交错。此时在卡片根节点主动建组,内部使用小范围数值即可;对外需要置顶时,比较的仍是卡片整体。Josh 用组件内层分组解释了这种边界。What The Heck, z-index??
上面的页头案例正好可以验证它的代价:先将 .task-content 改为 z-index: auto,再单独加上 isolation: isolate。如果没有其他改动,菜单又被限制在内容组内,无法用 999 越过页头。它没有把菜单“修好”,只是重新建立了分组。
因此,纯内部装饰组合可以主动隔离;包含需要跨卡片显示的菜单时,先决定浮层放在哪里,再决定是否隔离卡片。不要给所有容器批量加 isolation 或 transform 作为修复。验收时在卡片旁放一个具有自身层级的邻居,分别检查内部装饰不交错、跨卡片浮层又能按预期显示,这两个目标要同时满足。
5. 分开处理边界、深度与 Focus indicator
表面角色的选择见主文。本节固定背景与角色,观察 box-shadow、边框和 outline 的参数;阴影实验参考 Josh 的公开文章。
先知道每个参数在改变什么
以 box-shadow: 0 8px 20px 0 rgb(30 45 70 / 0.12) 为起点。下面按参数含义设计实验,每次只改一项;这些数值用于观察,不是推荐所有页面采用同一档位。MDN:box-shadow
| 参数 | 本例数值 | 单独改变后的预测 | 不应拿它代替什么 |
|---|---|---|---|
| 水平偏移 | 0 | 改为 8px,阴影向右偏;负值向左 | 不负责移动卡片或为右侧留空间 |
| 垂直偏移 | 8px | 改为 16px,阴影与卡片的垂直错位更明显 | 不直接改变真实层叠顺序 |
| 模糊半径 | 20px | 增大后,明暗过渡更宽;改为 0 会得到清晰边缘 | 不等于扩展半径,也不保证边界仍清楚 |
| 扩展半径 | 0 | 改为 6px,模糊前的阴影轮廓向外扩;负值收缩 | 不会为邻居增加布局间距 |
| 颜色与透明度 | 蓝灰色、0.12 | 改为 0.3 更显眼;换色会改变它与背景叠加后的观感 | 不能只靠更黑来表示更高 |
有些阴影看起来像厚边框,是因为扩展过大;另一些像灰雾,是大模糊与较强颜色共同作用。先把模糊设为 0 看清轮廓,再恢复模糊,通常比同时修改四个数更容易判断问题。多个阴影按列表绘制,前面的在上层;外阴影不占布局空间,因此容器若贴得太近,阴影仍可能覆盖邻近内容或被祖先裁切。CSS Backgrounds:阴影形状与绘制
下面三张表面仅改变扩展或模糊,背景、偏移与透明度相同。
基准
模糊 12px,扩展 0。
只增加扩展
模糊 12px,扩展 6px。
只增加模糊
模糊 24px,扩展 0。
用少量表面角色建立可比较的深度
本例让卡片使用细轮廓,让菜单叠加近处窄阴影与外侧宽阴影。小阴影强调边缘,大阴影提供扩散;增加层数会改变叠加强度,真实覆盖顺序仍由上一节的机制决定。模态确认还需要背景处理与交互限制,下面的表面 CSS 没有实现这些行为。
任务卡片
细轮廓区分内容,不强调高度。
临时菜单表面
窄阴影与宽阴影组合表达浮起。
下面的 CSS 应用于已有表面元素。零偏移、零模糊、带扩展半径的阴影可以形成轮廓;它本身不自动具有“焦点”含义。MDN:box-shadow
.task-card {
background: white;
border-radius: 10px;
box-shadow: 0 0 0 1px rgb(30 45 70 / 0.18);
}
.task-popover {
background: white;
border-radius: 10px;
box-shadow:
0 1px 2px rgb(30 45 70 / 0.10),
0 8px 20px rgb(30 45 70 / 0.12);
}
验收:把卡片和浮层放在同一背景上比较,确认深度有区别、阴影方向协调,文字附近没有明显脏灰圈。切换深色背景后重新设计表面和轮廓,不能直接假设浅色示例仍然有效;阴影也不会改变真实的布局与层叠顺序。
深色背景先解决表面分离,再增加阴影
在很暗的背景上,增加黑色阴影不一定能让边界更清楚。先比较画布、常驻卡片和浮层的表面色,再决定边缘是否需要细线。把黑影直接改为大面积白色模糊容易形成发光感;如果目标只是区分表面,可以先试轻微的亮边界,并保留较克制的暗影。这是本例的配色选择,不是深色界面必须采用的统一公式。
下面手动切换 data-theme="light" 与 data-theme="dark",比较同一组角色;它不读取系统主题。两个表面都保留边界,所以临时移除浮层阴影后仍应能够辨认轮廓。
<section class="surface-study" data-theme="dark" aria-label="表面层级练习">
<article class="surface-panel">
<h3>常驻任务卡片</h3>
<p>任务状态与附件信息。</p>
</article>
<aside class="surface-panel surface-floating">
<h3>临时说明表面</h3>
<p>只在需要时展示的补充内容。</p>
</aside>
</section>
<style>
.surface-study {
--canvas: #eaf0f2;
--surface: #ffffff;
--raised: #ffffff;
--ink: #18313d;
--edge: rgb(30 45 70 / 0.18);
--shadow: rgb(10 20 30 / 0.14);
display: grid;
gap: 24px;
padding: 28px;
background: var(--canvas);
color: var(--ink);
}
.surface-study[data-theme="dark"] {
--canvas: #0f1820;
--surface: #192732;
--raised: #253642;
--ink: #edf4f8;
--edge: rgb(255 255 255 / 0.16);
--shadow: rgb(0 0 0 / 0.32);
}
.surface-panel {
padding: 20px;
border: 1px solid var(--edge);
border-radius: 10px;
background: var(--surface);
}
.surface-panel h3 { margin: 0 0 8px; }
.surface-panel p { margin: 0; line-height: 1.6; }
.surface-floating {
background: var(--raised);
box-shadow: 0 2px 4px var(--shadow), 0 12px 24px var(--shadow);
}
</style>
输入变更预测:把深色模式的 --raised 改成与画布相同的颜色,并删除边界,观察浮层是否难以区分;还原边界后再比较。只调整阴影无法补救正文颜色与表面颜色过近的问题,文字对比需要单独检查。也不要为了看到完整阴影而删除业务上需要的滚动裁切,应按上一节调整结构。
边界、焦点与运动分别验收
边框适合持续表达表面边缘,零模糊阴影可提供不占布局空间的轮廓;outline 可以在外围画出焦点提示,但“哪种属性”本身不决定语义,必须由对应状态触发。添加边框还要检查盒模型,避免状态切换时尺寸或可用内容宽度突然变化。MDN:outline
最后在同一页面检查三件事:常驻表面是否分组清楚,临时浮层是否有可比较的深度,Tab 移动时焦点是否持续可见。不能因为卡片有一圈阴影,就认定按钮也具备了焦点提示。
需要浮起动画时,先评估能否移动独立视觉层,而不是让大量元素持续变化多层阴影。Josh 的按钮示例采用了独立表面与阴影层的运动;本文只借用这个实现思路,具体性能仍应在实际元素数量与目标设备上量测,不能给出固定倍数的收益。Building a Magical 3D Button
6. 按钮分别回应指向、按下、焦点与完成
Josh 的按钮文章把按压感拆成静止的底层与移动的表面,按钮的布局尺寸可以保持稳定。本文示例进一步在外层预留 6px 空间,使内部表面的 2–4px 位移始终留在按钮框内;仍需检查边缘命中。原文也专门讨论了焦点轮廓。Building a Magical 3D Button
工程实现应明确几个状态的职责:
| 状态 | 用户需要知道什么 | 任务页中的例子 |
|---|---|---|
| 指向 | 这里可以操作 | 支持悬停的设备上,按钮表面略微抬起 |
| 按下 | 已接收到当前按压 | 立即产生轻微位移,不等待网络 |
| 焦点 | 下一次键盘操作将作用在哪里 | 按钮外围出现清晰的 Focus indicator |
| 提交中 | 请求仍在处理 | 保持尺寸,提供状态文字并防止重复提交 |
| 完成或失败 | 结果究竟怎样 | 收到真实结果后显示“已保存”或具体错误 |
按下反馈与业务完成需要分开。按钮动了一下只能说明输入被接收,不能说明服务端已经保存。
下面是一个完整、无依赖的 HTML/CSS 按压示例,只演示视觉与焦点状态,不执行保存请求。保持原生 <button>,不要用只有点击处理器的 <div> 替代。
<button class="save-button" type="button">
<span class="save-face">保存修改</span>
</button>
<style>
.save-button {
padding: 6px 0 0;
border: 0;
border-radius: 8px;
background: linear-gradient(transparent 6px, #13364a 6px);
color: white;
font: inherit;
cursor: pointer;
}
.save-face {
display: block;
min-height: 44px;
box-sizing: border-box;
padding: 11px 20px;
border-radius: inherit;
background: #24566e;
transform: translateY(-2px);
}
.save-button:focus-visible {
outline: 3px solid #172b35;
outline-offset: 4px;
}
@media (hover: hover) {
.save-button:not(:disabled):hover .save-face {
transform: translateY(-4px);
}
}
.save-button:not(:disabled):active .save-face {
transform: translateY(0);
}
@media (prefers-reduced-motion: no-preference) {
.save-face { transition: transform 160ms ease-out; }
.save-button:not(:disabled):active .save-face {
transition-duration: 40ms;
}
}
</style>
:focus-visible 根据浏览器启发式决定何时需要明显的焦点提示,并不严格等于“只在键盘操作时生效”。示例保留浏览器的默认焦点处理,没有全局移除 outline。如果需要适配深色背景,应提供相应的轮廓色和背景区分,而不是固定复制这个深色描边。MDN::focus-visible
示例仅在设备支持悬停时加入悬停效果;减少动态效果偏好开启时保留状态差异,取消平滑位移。这些是本文在案例中补充的条件。MDN:hover、prefers-reduced-motion
验收:分别用鼠标、Tab 后按 Enter/Space、触屏执行操作,检查命中区域是否稳定、焦点是否可见。开启减少动态效果后再操作,状态仍可辨认;连接真实请求时,另用慢请求与失败响应检查提交中和结果状态。
焦点、悬停和选中不是同一种状态
鼠标经过“保存修改”时,用户还没有决定执行;Tab 抵达按钮时,用户只是选择了下一次键盘操作的目标。两者都不应改变任务数据。选中的筛选条件、当前导航项和被按下的开关,则属于持续状态,指针移开或焦点移走后仍然需要辨认。把它们都做成同一块深色背景,会掩盖状态之间的差别。
例如,任务列表的“仅看未完成”可以使用原生复选框;查看详情使用链接;保存使用按钮。若操作只改变本页一个可展开区域,可用带可见标签的按钮或原生 details。不是每个看起来像按钮的东西都应该绑定同一种点击逻辑。MDN:button、MDN:a
| 交互意图 | 合适的起点 | 容易出现的错误 |
|---|---|---|
| 跳到附件的独立页面 | 有真实 href 的链接 | 用按钮模拟导航,丢掉打开新标签页等链接能力 |
| 提交当前修改 | 表单内的提交按钮 | 只监听按钮 click,漏掉表单的其他提交入口 |
| 保存一个开/关偏好 | 复选框,或实现完整语义的开关 | 点击后变色,却不暴露当前是否选中 |
| 暂时打开操作面板 | 触发按钮和相应浮层 | 将可见性、当前选择和键盘焦点混为一个状态 |
| 当前无法再次执行 | 原生 disabled,或有拦截逻辑的 aria-disabled | 只降低透明度,实际仍允许提交 |
原生 disabled 会阻止控件操作,并使其退出正常的 Tab 顺序。aria-disabled="true" 只表达不可用语义,不自动阻止鼠标、键盘或表单处理。需要保留焦点时可以选择后者,但代码必须真正拦截重复动作,并提供可见状态。MDN:disabled、MDN:aria-disabled
把保存做成状态转换,避免“按了却不知道发生什么”
下表是后面练习采用的状态模型。失败恢复与提示方式的选择见主文;这里重点核对每次结果属于哪个请求,以及输入与焦点怎样保留。
| 状态 | 按钮与内容 | 进入条件 | 离开条件 |
|---|---|---|---|
| 有未保存修改 | 保留输入,允许保存 | 用户编辑了字段 | 校验通过后提交 |
| 正在保存 | 立即显示处理文字,阻止重复提交,保留焦点位置 | 本次请求已经开始 | 收到对应结果,或用户停止等待 |
| 已保存 | 保留新值,显示可确认的结果 | 本次提交得到成功结果 | 用户再次编辑 |
| 保存失败 | 保留输入,说明下一步可以重试 | 对应请求失败 | 用户修改或重试 |
| 已停止等待 | 保留输入,明确说明当前只停止了等待 | 用户请求停止当前等待 | 核对真实结果,或开始新操作 |
这张表中的“对应”很关键。一次提交完成时,如果用户已经换到另一条任务,旧请求不能把新页面改成“已保存”;如果允许保存期间继续编辑,成功结果也只能确认提交时的快照,不能直接宣布后来输入的内容已经保存。可以选择冻结当前可编辑字段,或为草稿与提交快照分别记录版本。下面的练习采用前者,让状态边界容易观察。
一个可以失败、重试和停止等待的完整练习
将下面整段保存为 HTML 文件即可运行。它只使用本地计时器模拟 1.2s 延迟,没有网络请求,也不会存储输入;勾选“模拟失败”即可检查错误路径。原生表单校验先处理空值,submit 监听器接收通过校验的提交,不把提交行为绑定到单一鼠标点击。MDN:submit event
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>任务保存状态练习</title>
<style>
body { max-width: 34rem; margin: 2rem auto; padding: 1rem;
color: #172b35; font: 16px/1.6 system-ui, sans-serif; }
label { display: block; margin-block: 0.75rem; }
input[type="text"] { display: block; box-sizing: border-box;
width: 100%; padding: 0.7rem; font: inherit; }
.form-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
button { min-height: 44px; padding: 0.6rem 1rem; font: inherit; }
button:focus-visible { outline: 3px solid #24566e; outline-offset: 3px; }
button[aria-disabled="true"] { opacity: 0.65; cursor: wait; }
#save-status { min-height: 3.2em; }
</style>
<h1>修改任务名称</h1>
<form id="task-form" aria-describedby="save-help">
<label for="task-title">任务名称</label>
<input id="task-title" type="text" required maxlength="80"
value="整理活动页面">
<label><input id="simulate-failure" type="checkbox">模拟失败</label>
<div class="form-actions">
<button id="save" type="submit" aria-disabled="false">保存修改</button>
<button id="stop" type="button" disabled>停止等待</button>
</div>
</form>
<p id="save-help">本地模拟:等待约 1.2 秒。输入不会上传或持久化。</p>
<p id="save-status" role="status" aria-atomic="true">有未保存修改。</p>
<script>
const form = document.querySelector('#task-form');
const titleInput = document.querySelector('#task-title');
const failureInput = document.querySelector('#simulate-failure');
const saveButton = document.querySelector('#save');
const stopButton = document.querySelector('#stop');
const statusText = document.querySelector('#save-status');
let activeController = null;
function pending(value) {
form.setAttribute('aria-busy', String(value));
saveButton.setAttribute('aria-disabled', String(value));
saveButton.textContent = value ? '正在保存…' : '保存修改';
titleInput.readOnly = value;
failureInput.disabled = value;
stopButton.disabled = !value;
}
function simulatedSave(title, shouldFail, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException('已停止本地等待', 'AbortError'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
if (shouldFail) reject(new Error('模拟失败,请保留输入并重试。'));
else resolve({ title });
}, 1200);
signal.addEventListener('abort', onAbort, { once: true });
});
}
titleInput.addEventListener('input', () => {
titleInput.setCustomValidity('');
if (!activeController) statusText.textContent = '有未保存修改。';
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (activeController) return;
const title = titleInput.value.trim();
if (!title) {
titleInput.setCustomValidity('请输入任务名称。');
titleInput.reportValidity();
return;
}
const shouldFail = failureInput.checked;
const controller = new AbortController();
activeController = controller;
pending(true);
statusText.textContent = '正在保存,请稍候。';
try {
const result = await simulatedSave(title, shouldFail, controller.signal);
if (activeController === controller) {
statusText.textContent = '已保存任务名称:' + result.title;
}
} catch (error) {
if (activeController === controller) {
statusText.textContent = controller.signal.aborted
? '已停止本地等待,输入仍然保留。'
: error.message;
}
} finally {
if (activeController === controller) {
const returnFocus = document.activeElement === stopButton;
activeController = null;
pending(false);
if (returnFocus) saveButton.focus();
}
}
});
stopButton.addEventListener('click', () => activeController?.abort());
</script>
</html>
这里有几处与“加一个 loading 图标”不同的决定。第一,保存按钮使用 aria-disabled 保留原焦点,同时由 activeController 拦截重复提交。第二,状态文字位于标成忙碌的表单之外,使用 role="status" 提供非打断式结果提示;不为了显示一个成功消息强制移动焦点。第三,等待结束时,若焦点原来在即将禁用的停止按钮上,才把焦点交回保存按钮。MDN:aria-busy、MDN:status role
AbortController 负责发出取消信号,不是数据库回滚机制。这个本地模拟可以确知计时器被停止;接入真实保存接口时,中止客户端等待并不能证明服务端没有保存。产品应根据接口契约查询结果或提示用户核对,不能直接显示“修改已撤销”。处理错误时也应把字段错误、网络状态不明和业务拒绝分开,避免所有情况都只显示“请重试”。MDN:AbortController、W3C:表单结果提示
验收:正常保存、模拟失败、失败后重试、正在保存时重复按 Enter、停止等待后立即再保存,分别检查输入、状态文字、按钮可用性和焦点。用失败路径验证“保留输入”,用重复提交路径验证“真正拦截”,不能只看成功路径的动画是否流畅。读屏提示还需在目标浏览器与辅助技术组合中单独检查。
7. 手势、动画与返回的实现边界
动画取舍和返回体验见主文。这里保留实现需要区分的状态与异常输入,没有提供完整拖动或路由应用。Rauno 的 2023 年长文包含系统观察与原型,不能当作当前跨平台保证或浏览器内建能力。Invisible Details of Interaction Design
输入、动画和请求分别管理
命令面板的入场动画、字段能否接收输入、服务端查询是否完成,是三件事。快速输入两个查询时,旧结果不能覆盖新选择;关闭后立即重开时,旧查询和离场回调不能重新打开面板、抢焦点或清除新输入。
输入检查:连续二十轮“打开→输入→下移→确认”,再执行输入后立即关闭并重开。比较有无动画时的丢键、等待与旧回调;二十轮只是本文回归样本,不是标准或用户研究。
Direct manipulation 的临时值与确认值
拖动开始时分别保存已确认值与当前可见位置,移动只改临时位置,释放后再选择吸附目标。吸附途中重新拖动要从当前可见位置接管,旧完成回调不能继续改写新操作。下表为本文最小状态模型,实际命名可不同。
| 当前阶段与输入 | 画面怎样回应 | 数据与取消语义 |
|---|---|---|
| 静止时按下 | 记录当前可见位置,准备接收本次指针输入 | 记录本次操作前的已确认值,不发送删除或保存请求 |
| 拖动中移动 | 根据位移更新临时位置,反馈从开始移动时就出现 | 只改预览;越过视觉阈值不等于已经提交业务动作 |
| 拖动中取消,或按约定按 Esc | 清理本次拖动,回到本次开始前的已确认状态 | 丢弃尚未提交的临时值;不会因此产生删除请求 |
| 正常释放 | 选择目标位置并吸附,或停在约定的最终预览 | 对说明面板可确认展开状态;对任务行仅确认删除入口已展开 |
| 吸附途中再次按下 | 从此刻的可见位置接管,停止旧吸附继续写入 | 新输入拥有这次预览;旧完成回调不能提交或重置它 |
| 明确点击删除、请求处理中 | 保持所操作任务的身份,显示真实处理中状态 | 防止重复提交;关闭面板不等于服务端已经撤销请求 |
| 请求成功或失败 | 成功后更新对应任务;失败时保留可理解的错误与恢复入口 | 只处理仍对应当前任务和当前请求的结果,不把旧失败写到新任务上 |
Pointer capture 让元素在指针离开边界后继续接收本次事件;pointercancel 可能由浏览器接管滚动、缩放或系统中断引起,不能当作正常释放再提交。pointerup 或 pointercancel 后会隐式释放 Pointer capture,失去捕获也不等于撤销已完成业务。MDN:Pointer events、pointercancel
取消未提交的预览可以丢弃临时值;请求已发出后关闭面板,不能证明服务端已撤销。不要让同一个重置函数同时回滚拖动和抹掉请求状态。对迟到结果,核对任务与请求身份后再更新页面。
WCAG 2.5.2(A)允许几种指针取消合规方式,不要求全部控件采用同一种确认框。2.5.7(AA)要求拖动功能有无需拖动的单指针替代,存在拖动必不可少、功能由用户代理提供且未被作者修改等例外;仅有键盘快捷键不足以满足后者。Pointer Cancellation、Dragging Movements
输入检查:拖一半反向、越过阈值再移回、松手后立即重拖、模拟指针取消;请求延迟时关闭面板并打开另一条任务。检查旧动画与结果都不会改写新对象,也没有尚未确认的删除请求;再用点击与键盘分别检查替代入口。
触摸读数与列表返回
五分钟音频线性铺在 300 CSS 像素上,约为一秒对应一像素;平滑动画不会提高这段轨道的选择精度。需要十分之一秒时,可另设局部放大、步进或数值输入。读数要检查手指遮挡与轨道两端的边界,同时区分临时值和已保存值;指针移出后继续追踪,也不等于应禁用全页滚动或缩放。
返回列表的恢复数据可包括查询、排序、已加载范围、原任务 ID 与条目相对滚动容器的位置。先恢复条件,待条目具备可定位布局后再恢复位置;虚拟列表先挂载条目。原条目已删除或不符合筛选时,给出可解释的相邻内容或列表起点,不滚向旧坐标。
history.scrollRestoration 的 auto 与 manual 控制浏览器历史导航的滚动恢复。选择 manual 时,应用要承担恢复流程,避免路由、浏览器和列表组件相互争抢滚动;焦点、迟到图片与用户已主动滚动的情况也需协调。MDN:History.scrollRestoration
输入检查:真机左右手操作滑杆;列表上方插入内容或加载图片后返回,再分别检查原条目删除、排序改变、虚拟列表未加载和用户提前滚动。这里的恢复模型是本文建议,不是原文滚动地标原型的现成功能。
对象身份与新输入的接管
预览用稳定的任务或附件 ID 关联来源;展开前读取当前位置,不将“第几行”或旧坐标当作对象身份。来源已删除、滚出视口或虚拟列表未挂载时,采用合理的普通显示与退出,不飞向错误记录。关闭途中重开时,旧卸载回调必须核对当前对象与操作;减少动画时仍保留名称、选择与焦点关系。
控件设置变化后意外跳转也有独立限制:WCAG 3.2.2(A)要求不要自动造成上下文变化,除非事前告知;这不禁止所有局部内容更新。可推断的字段值保持可见、可修改,用户刚作出的选择优先于旧偏好,切换筛选后核对批量操作对象。On Input
WCAG 2.3.3(AAA)要求交互触发的非必要运动动画可以禁用。自动开始、持续超过五秒并与其他内容同时展示的移动、闪烁或滚动内容,还要按 2.2.2(A)的适用条件提供暂停、停止或隐藏,存在必要活动等例外;两条不能混称 AA。Animation from Interactions、Pause, Stop, Hide
8. 输入检查与历史实测记录
完整评审与交付方法见主文。本篇按实现输入留证据,尺寸量测和单个事件路径不能代替跨步骤任务检查。
| 实现范围 | 改变的输入或条件 | 应记录的结果 |
|---|---|---|
| 正文与任务页 | 长标题、中英混排、字体替换、页面放大、紧凑表格 | 换行、完整内容、金额语义与溢出范围 |
| Flex 与长名称 | 单独修改基础尺寸、系数、下限、间隙和容器宽度 | 实际边界与推导差异;完整名称入口是否可操作 |
| 层叠与表面 | 页头、滚动容器、菜单同时出现;浅深背景切换 | 遮挡还是裁切;命中元素、轮廓和焦点 |
| 保存练习 | 成功、失败、重试、重复 Enter、停止等待后再保存 | 输入值、请求归属、状态文字、可用性与焦点 |
| 手势与返回 | 反向、取消、重开、迟到结果、返回目标失效 | 当前对象、临时与已确认状态、最终落点 |
本文按钮从 44px 高度开始练习;WCAG 2.2 的 2.5.8(AA)规定指针目标至少 24 × 24 CSS 像素,或满足间距等列明例外,44px 不是该条款的统一门槛。图标视觉尺寸与真实命中范围要分别检查。Target Size (Minimum)
WCAG 2.4.7(AA)要求键盘可操作界面存在焦点可见的操作模式;示例的 3px 轮廓只是实现参数,不能据此宣称整个页面符合所有无障碍要求。Focus Visible
以下为 2026-10-22 已执行的历史核验。2026-10-23 的整理保留原代码与记录,没有重新运行这些实验。
本文示例的实际核验
2026-10-22 的初版核验将六段 HTML/CSS 示例分别放入独立页面,在桌面浏览器检查:Flex 两项实测各为 150px,改为普通布局后各为 200px;长附件名在 240px 容器中换行且未产生页面横向溢出;菜单重叠处的命中元素随内容组的 z-index 从 1 改为 auto,由页头变为菜单;按钮可通过 Tab 聚焦并用 Enter/Space 操作,焦点轮廓为示例设置的 3px,初始视觉表面位于按钮框内。正文与金额表的行高、对齐和数字字形属性也已检查。
同日扩充后,新增练习又在独立的标准模式 HTML 页面中完成以下检查;HTML 与紧随其后的 CSS 配方按正文说明合并运行。
| 实际执行的输入 | 观察结果 |
|---|---|
| 六组 Flex 参数分别运行 | 依次得到 192/128、约 213.34/106.66、220/100、180/180 并溢出、290/210、250/250;与推导一致,分数尺寸有浏览器取整 |
将完整任务页约束到 304px 宽 | 页面内部未发生横向溢出,费用合计仍为 1,021.90 |
| 用 Tab 和 Enter 操作完整文件名入口 | 原生 details 展开,完整名称与当前附件一致 |
| 在裁切容器内打开原生说明 | Popover 可见且绘制范围超出裁切祖先;Esc 可以关闭 |
| 分别运行浅色与深色表面配方 | 表面色、边界与阴影按各自变量变化,没有页面横向溢出 |
| 正常保存、处理中重复按 Enter、模拟失败与重试 | 处理中冻结输入并暴露不可用状态;结束后恢复可操作,失败保留名称 |
| 停止等待后再次保存 | 本地等待停止,输入保留,焦点回到保存按钮;下一次提交可以完成 |
| 纯空白名称,再改为有效名称 | 空白被明确校验消息阻止,后续输入可清除错误并成功提交模拟操作 |
这里验证的是本文代码在桌面浏览器里的尺寸与事件路径。真实服务端保存、真机触摸、辅助技术播报、减少动态效果及动态列表返回,仍需要在目标产品中执行对应检查;第 7 节的跨步骤场景没有被包装成已实现的拖动应用,也没有执行用户研究。
继续阅读
- 网站 UI 设计主文:内容结构、视觉规则、交互方法、交付流程与资料选读。
- Frontend 主题导航。
原始交互示例仍可在 Josh 和 Rauno 的网页操作;遇到具体排版问题时,可按 Practical Typography 的章节查阅。