面向工程师的 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 未取得任务级运行记录。
改进工程页面,可以先让正文好读、信息关系清楚,再让布局行为可预测,最后处理视觉深度和操作反馈。这个顺序是本文的工作建议:前面的结构问题没有解决时,增加阴影和动画通常无法解释页面为什么难用。
全文用一张虚构的“任务详情页”练习:页面包含任务标题、说明、附件名、费用表、操作菜单和保存按钮。每一步都回答四个问题:哪里不顺、为什么发生、怎么改、如何判断改动有效。文中的尺寸、颜色和时长是实验起点,需随字体、内容、背景和输入方式调整。
先认识几个术语
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Typography | 文字排版 | 安排字体、字号、行距与页面中的文字关系 |
| Layout mode | 布局模式 | 浏览器计算元素尺寸和位置所依据的一组规则 |
| Flow | 正常文档流 | 块级内容与行内文字按正常阅读顺序排布 |
| Flexbox | 弹性盒布局 | 沿主轴分配空间,协调项目的增长、收缩和对齐 |
| Grid | 网格布局 | 用共享的行列轨道组织项目位置与对齐关系 |
| Stacking context | 层叠上下文 | 将一组元素的前后层级作为一个整体参与外部比较 |
| Top layer | 顶层 | 浏览器维护的绘制层,供打开的 Popover 等元素显示在普通内容之上 |
| Focus indicator | 焦点指示 | 告诉用户当前哪个控件将接收键盘操作的可见提示 |
| Direct manipulation | 直接操控 | 拖动等输入发生时,界面对象立即随输入变化 |
1. 从最长的正文开始,确定阅读尺度
Practical Typography 把正文作为排版的起点:它通常构成文档的主体。作者关注字体、字号、行距与行长,而不只是标题的样式。工程上可以先放入真实的三段说明,包含中文、英文术语、链接与数字,再调整其余元素。Body text、Typography in ten minutes
先固定字体,再分别调宽度与行高
任务说明如果横跨整个宽屏,读者回到下一行时容易失去位置;如果所有文字都缩小以“塞进去”,正文与控件标签也会一起变得难读。应先限制正文宽度,再观察换行,而不是让整页文字承担容器过宽的后果。
Butterick 给出的西文参考为每行平均 45–90 个字符、行高约为字号的 1.2–1.45 倍。这些数值是他的排版建议,不能改写成“中文每行 45–90 个汉字、行高也必须如此”。同一字号在不同字体中看起来也不一样。Line length、Line spacing、Point size
下面保持字号和文字相同,只改变行高。它展示的是一个变量的影响,不代表右侧参数适合所有内容。
对照 A · 16px / 1.2
本任务需要整理页面中的附件与费用。提交前,请核对文件名称、处理状态和预计完成时间。包含 API 名称与数字 128 的说明,也应保持清楚的阅读节奏。
对照 B · 16px / 1.65
本任务需要整理页面中的附件与费用。提交前,请核对文件名称、处理状态和预计完成时间。包含 API 名称与数字 128 的说明,也应保持清楚的阅读节奏。
可以从下面这段无依赖的 HTML 开始。保存为 HTML 文件即可查看;本例的 1.65 是中文练习档位,不是原书参数。
<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 长度单位定义
验收:固定字体后,分别改变宽度与行高;用实际内容读完几段,观察是否难以回到下一行、是否容易串行。再检查中英混排、行内代码与页面放大后的换行。检查发现的是具体阅读问题,不能仅凭一次截图宣称某个字号“已证明最好”。
用样式表达段落关系,用角色约束字体数量
说明文字应使用独立的 <p>,通过段间距保持节奏,避免插入多次 <br> 撑开。标题要看起来属于后面的段落:可以先试较大的上间距和较小的下间距。正文采用段间距后,也不必机械叠加首行缩进。Space between paragraphs、Headings
任务页可以先只设置正文/UI 字体和代码字体两种角色,让任务标题、标签和按钮通过字号、字重与留白区分。这里的“两种”是简化练习,不是字体数量上限;中文与拉丁字符的正常字体回退也不等于随意混搭。Butterick 对专业字体的购买偏好属于他的观点,不构成工程项目必须购买字体的要求。Mixing fonts
验收:插入、删除一个段落后,节奏依然一致;扫读标题可以预测内容结构;同一种信息在不同卡片上使用同一种字体角色。
先检查字体能否完成任务,再比较风格
选字体时,可以先做一张试样,而不是只看字体名称或一句英文宣传语。把任务页真正会出现的内容放在一起:任务审核、API request、build-128、1,021.90,再放入容易混淆的 I / l / 1 和 O / 0。分别查看正文、短标签和较重的标题。本文关心的是,读者能否区分内容、不同语言是否协调、字体能否承担需要的角色;不会因为某个字体在海报里好看,就假定它也适合费用表。
| 任务页的字体角色 | 先检查什么 | 不合适时怎样调整 |
|---|---|---|
| 正文与控件 | 连续读两三段是否吃力;中文、标点与英文是否大小协调 | 先比较字体与字号,再调行长、行高;不要先给整页加字距 |
| 标题与状态 | 400 和 600 等候选字重是否能区分,是否仍易认读 | 使用字体实际支持的字重;区别不够时配合间距,不继续堆粗体 |
| 费用数字 | 数字能否沿列比较,小数点和单位是否清楚 | 检查等宽数字字形,统一展示格式;不必把所有正文换成等宽字体 |
| 文件名与代码 | 相似字符能否辨认,较长内容怎样换行 | 只给这一角色使用代码字体,并保留复制所需的原始内容 |
浏览器按字符选择字体。列表前面的字体缺少某个汉字时,会继续寻找可用字形;因此,一段文字可能同时使用多套字体。font-family 中写了一个拉丁字体名,并不代表中文也由它显示。字体列表最后应保留通用字体族,实际结果还要在目标系统上检查。MDN:font-family
这个任务页可以先用 system-ui, sans-serif 承担正文和控件,用 ui-monospace, monospace 显示文件名。这样先减少需要下载和搭配的字体,也接受各系统字形略有不同的取舍。如果产品确实需要统一品牌字形,再评估包含哪些字符与字重、网页使用许可和加载行为。Butterick 的字体评价包含鲜明的审美偏好;这里采用的是按内容与运行条件筛选的工程方法。Font basics、System fonts
引入网络字体后,还要检查未加载时的回退状态和加载后的状态。font-display 控制等待和替换策略,但不会保证两套字体的字宽相同。本文建议在慢网络或屏蔽字体请求时看一次任务标题、按钮与金额,确认不会因为换字形而丢失内容或把操作挤走。MDN:font-display
字号、字重与字距各自解决什么
如果同一页把标题设得又大又粗、字距又紧,把备注设得小而疏,状态再使用第三种字体,就同时改变了很多变量,很难知道哪一项起了作用。本文建议先定正文,再给标题、分区、标签分配少量角色;需要加强某一处时,先改一个变量,再与原状态比较。
| 调节项 | 本例用法 | 需要付出的代价 |
|---|---|---|
| 字号 | 正文 1rem,分区标题 1.125rem,任务标题 1.5rem | 字号增加会改变换行与页面高度,不能只在短标题上看效果 |
| 字重 | 正文 400,标题和关键值从 600 开始试 | 字重太多会彼此竞争;字体未必提供每一档字形 |
| 颜色 | 正文使用主要文字色,标签适当减弱 | 降低颜色强度会损失可读性,不能靠浅到看不清来制造层级 |
| 统一字距 | 正文先保留 normal;短英文全大写标签单独试 | 改动会影响每一对字符、行长与换行,过宽会把单词拆散 |
Butterick 提醒,粗体和斜体应承担局部强调,整段都强调会失去重点。任务说明里可以只强调“提交后不可修改”,把其余解释留在正常字重;当整个段落都很重要时,更适合重写层级或单独安排提示区。Bold or italic
字重数值也不是视觉粗细的通用刻度。写出 font-weight: 600,不代表所有字体都有相同外观,也不代表当前字体必然存在独立的 600 字形。浏览器会按可用字重匹配;验收时应查看实际渲染的字形,而不是只确认开发者工具显示了声明值。MDN:font-weight
统一字距作用于一段文字中各对字符;字偶距则利用字体信息,调节特定字母组合之间的距离。前者适合讨论短英文标签的整体疏密,后者处理的是局部配对。Butterick 关于全大写增加字距的建议针对西文,不能转成“中文正文都加 0.08em”。本例正文保留默认字距;只有独立的英文小标签才试该数值。Letterspacing、Kerning
/* 本文实验参数:分别控制角色,不放在全局 body 上。 */
.task-sheet { letter-spacing: normal; }
.task-sheet .eyebrow[lang="en"] { letter-spacing: 0.08em; }
.task-sheet .latin-title { font-kerning: normal; }
font-kerning: normal 要求应用字体中的字偶距信息;它不会替字体生成不存在的配对。letter-spacing 也没有一个能保证所有字体都好读的固定值。改完后应同时看短标签与长标题,并确认该规则没有从父元素意外继承到代码或中文段落。MDN:font-kerning、letter-spacing
中英混排先保护内容,再处理标点与断行
同一页里,说明文字和机器可读内容需要不同处理。中文句子可以写“请选择‘重新整理’”,代码中的 "status": "ready" 则必须保留语法需要的直引号。Butterick 对弯引号、连字符与破折号的讨论适合帮助检查自然语言排版;应用到工程页面时,仍要保护命令、URL、文件名和可复制数据。Straight and curly quotes、Hyphens and dashes
下面是本任务页的内容约定。它们是针对示例的编辑选择,不是对中英混排规则的完整规定。
| 内容 | 本例怎样显示 | 保留的边界 |
|---|---|---|
| 中文说明 | 预计 3 个工作日完成,请核对 API 名称。 | 对相同类型的中英边界保持一致,不给所有标点机械添加空格 |
| 短字段与解释 | 状态:待审核 | 同一字段的冒号、单位和标点保持一致 |
| 英文范围 | Pages 12–18 | 文件名 build-128.pdf 中的连字符保持原样 |
| 数值与单位 | 128 MB | 只把短而不可拆的量绑在一起,不把整段说明设为不换行 |
| 机器可读片段 | {"status":"ready"} | 不自动替换引号,不插入装饰空格,不为视觉整齐改写值 |
不换行空格可以避免应当一起读的短片段在中间分开,但它不是对齐工具。连续插入多个 来摆齐字段,会让宽度变化时的布局难以控制;字段对齐应交给布局样式。Nonbreaking spaces
中文还应检查括号、引号和句末标点在行边缘的实际位置;同样宽度下,字体回退也可能改变结果。W3C《中文排版需求》单独讨论了行首行尾禁则与混排处理,说明这些问题不能用一组西文字符数覆盖。该资料当前为草案,本文仅把它作为排查入口,不把其中书籍版式的数值当作网页统一规范。中文排版需求
验收:用中文长句、英文长词、括号、短单位和一段可复制代码组成试样;字体加载前后与页面放大后各检查一次。可复制内容保持原值,短单位没有孤立到下一行,正文仍能换行;若规则造成大片空白或溢出,就缩小不换行处理的范围。
2. 用留白和对齐建立信息关系
正文可读以后,下一步是让信息容易比较。任务页中的“待审核”状态、附件和费用属于不同问题;不宜用同一种醒目颜色和同样大的字重竞争注意力。本文建议先按内容分组,再决定视觉强调:任务名称负责定位,状态回答进度,费用表用于逐行比较,主操作说明接下来会发生什么。
让标题更靠近它解释的内容
标题的大小告诉读者它有多重要,位置告诉读者它属于谁。假如“预计费用”到上面的附件列表和下面的金额表都是 24px,两侧关系就不明确。可以保持标题字号不变,把它与费用表的距离改为 8px,与前一组内容的距离保留为 24px;读者先看到的是一个整体。这是本文用于任务页的距离分配,不是通用的三倍公式。Butterick 同样建议标题下方的距离小于上方,让标题靠近它引出的内容。Space above & below
这类距离应按内容关系安排。一个字段的名称与值相隔 4px,一组字段之间相隔 16px,任务说明与费用区之间相隔 32px,便于区分字段、分组和分区。若所有层级都用同一间距,页面可能看起来规整,却无法说明归属;若每组都包进厚边框卡片,又会增加无必要的视觉边界。
下面两个静态示意使用同一组信息,右侧按文字角色调整字号、字重与距离。
对照 A · 全部强调,距离相同
整理季度附件
状态:待审核
请先核对文件名称和处理范围。
预计费用
人民币 1,021.90 元
对照 B · 区分任务、状态与费用
整理季度附件
状态:待审核
请先核对文件名称和处理范围。
预计费用
人民币 1,021.90 元
分组不是把一份写作提纲全部变成大标题。任务页的“文件名称”“处理范围”通常是字段标签;只有能帮助读者定位分区的内容,才需要标题层级。增加一个层级之前,可以先问:读者是否真的会根据它导航?这个判断借鉴了 Butterick 减少过细标题层次的主张,但不改变 HTML 标题应反映内容结构的职责。Headings
下面是原始数据与整理后的展示。示例中的金额、单位和状态均为虚构数据。
| 调整前的内容 | 调整后的表达 | 选择理由 |
|---|---|---|
| 页面里出现三个“确定”按钮 | “保存修改”“下载附件”“取消任务” | 标签直接说明动作,用户不必先猜上下文 |
| 状态、备注和标题全部粗体 | 标题突出;状态有文字标签;备注使用正常字重 | 强调保留给真正需要先看到的信息 |
金额写成 9.9 / 1000 / 12.00,全部居中 | 同一币种统一精度,金额列右对齐 | 更容易沿一列比较数量级与小数位 |
| 每个单元格都加粗边框 | 保留必要行分隔,增加单元格内边距 | 用空间区分内容,降低网格线的干扰 |
Practical Typography 的表格章节建议先去掉多余网格线,再按阅读需要补回。把这个原则用在费用表时,应先保证表头、单位和数字格式清楚;不是删除所有边界。Tables、Grids of numbers
<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
验收:隐藏装饰颜色后仍能识别标题、分区与状态;沿费用列可以比较金额;只看按钮文字就能知道动作。若需要依靠颜色判断状态,补足可见文字或其他明确线索。
让表格的数字格式服务于比较
右对齐有效的前提是这一列确实可以比较。金额先统一币种、单位和展示精度,再处理对齐;9.90 与 1,000.00 的小数位才能承担相同含义。若一行是人民币、另一行是美元,就应显示币种或分列,不能只保留相同的小数位。对于 12.4%、订单编号和文件大小,也应分别决定格式,而不是套用金额的两位小数。
本文建议先为每列写一句解释,再选展示方式:“这是人民币元,以两位小数展示”“这是标识符,保留前导零”“这是估算值,尚未得到实际金额”。这样可以提前发现把 00128 变成 128、把缺失值显示为 0.00 等问题。零表示一个确定数值,缺失或待估算需要相应文字;视觉上排得整齐,不能成为改变含义的理由。Butterick 的数字表格章节同样强调,位数排列应服从数字的含义。Grids of numbers
等宽数字适合按列比较金额,普通比例数字则可以留给说明段落。两者不等于“整个字体都等宽”:tabular-nums 仅请求数字字形特性,仍可与普通正文共用字体。费用需要小数点对齐、但各行精度必须不同的情形,应专门设计整数和小数部分的排列方式;本例采用固定精度,是为了把范围收在易复现的账单场景内。
在密度和辨认速度之间做取舍
一张只有三行费用的详情页,可以给单元格更多空间,帮助读者确认内容;每天处理数百行的审核表可能需要更紧凑的行距。两者有不同任务,不能因为一种截图“更清爽”,就把另一种密度判为错误。先确定用户是在持续阅读、逐行比较,还是快速定位少量信息,再调整。
在本例中,可以让正文保持不变,只把费用表的上下内边距从 0.75rem 调到 0.5rem,同时保留左右距离、金额格式和必要分隔线。这样增加可见行数时,仍能知道效果来自表格密度,而非文字整体缩小。如果每行还包含复选框或操作按钮,则要单独检查它们的操作范围;减少空白不能顺带缩小命中区域。多行备注出现时,让行高随内容增长,不要固定一个高度把文字裁掉。
验收:用三行、三十行和带换行备注的数据比较两种密度。先查指定项目,再沿列比较两笔金额;若容易看串行,补必要分隔或空间。保留“每行一圈边框”不是默认答案,全部去掉边界也不是;目标是让这张表的内容关系清楚。Tables
把排版决定落成一张任务页
把前面的单项实验收敛后,可以得到下面这组参数。它们服务于这张中文任务详情页,适合先复制验证,再按内容修改;不是从原书提取的设计系统。
| 角色或关系 | 本例参数 | 修改时先观察什么 |
|---|---|---|
| 页面范围 | 最大宽度 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 负责文字角色与距离。保存按钮只用于显示任务页结构,没有提交行为。已有项目应把 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 长度单位定义
改变任务标题、复制一段更长的中文说明,或者给表格加入一行多行备注,再检查参数之间的关系。若说明变成主要阅读内容,可先增加正文尺度;若页面变成批量审核工具,可先收紧表格上下内边距;如果字段本身含义不清,应先修改标签。不要为了保住固定高度,让所有文字都变小。
验收:这套配方需要在实际字体和页面中检查;浅色背景上的文字配色也需随实际主题调整。重点是长内容不丢失、标题仍属于正确分区、金额格式没有改变含义、页面放大后仍可阅读。网络字体替换与紧凑表格属于另外两组输入变化,不能只在默认数据下看一次就结束。
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
任务卡片需要与背景区分,菜单需要说明自己浮在内容之上,键盘操作需要说明焦点位置。它们都可能出现一圈轮廓,但回答的问题不同。先决定语义,再选择边框、阴影或 outline,更容易保持整页一致。
Josh 的阴影文章强调一致光源,并用分层阴影与协调的颜色改善深度表达。工程上可以先为少量表面角色定义样式,而不是每张卡片单独试一套参数。Designing Beautiful Shadows in CSS
先知道每个参数在改变什么
以 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 应用于已有表面元素。零偏移、零模糊、带扩展半径的阴影可以形成轮廓;它本身不自动具有“焦点”含义。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
以下将这些观察应用到任务页,场景与验收步骤是本文建议。任务状态、音频时长、恢复方案与异常用例均为教学构造;涉及浏览器行为和无障碍要求时,再分别引用 MDN 与 W3C。评审时先问清楚:用户正在预览、调整,还是确认提交;这一步能否反悔;下一次输入到来时,界面准备怎样接续。
高频动作:把能否连续操作放在动画之前
假设任务页有一个频繁打开的命令面板。用户想完成的是“打开、输入、选中、执行”,如果每次都必须等面板放大动画结束才能输入,动画就在阻断操作。
应让输入在界面出现时即可接收,让选项及时响应键盘;装饰运动不能决定业务何时可用。低频、需要解释对象关系的展开动作则可以保留适量运动,这取决于任务,而不是给所有状态套一个时长。
可以把同一个面板放进两种任务里比较。第一次使用批量分配功能的人,需要认清“当前选择了哪些任务、下一步会改变谁”,一次简短的区域展开或数量变化可能帮助他建立关系。已经熟悉快捷键的人,则可能在一天内反复打开面板、输入人名、确认分配;此时每次选项移动都附带弹跳,会不断重演他早已理解的信息。本文建议让这种连续键盘操作直接更新选中态,把解释性的运动留给真正改变结构的时刻。
这里要分开检查三件事:输入是否已经被处理,结果是否已经可以操作,装饰是否还在播放。面板淡入尚未结束,输入也可以正常写进字段;服务端还在查找人员,列表则应明确显示查询状态。取消入场动效不能消除网络等待,给搜索结果加一段动画也不能证明查询已经完成。若快速输入两个人名,较早的查询最后返回,还要避免旧结果覆盖新的选择。
频率变化也会改变取舍。保存成功后保留一段可读的结果反馈,和每次敲键都移动整张卡片,是两种不同的成本;导入完成后新增了一组任务,短暂说明新增位置可能有价值,而例行刷新同一列表时反复重播全部条目,反而会让位置难以追踪。可以在“无入场运动”和“仅结构变化时运动”之间做对照,记录用户是否漏看新任务、是否停下来等界面,而不只询问哪一版更漂亮。
验收:连续执行二十轮“打开→输入→下移→确认”,检查是否丢键、吞输入或被动画锁住;再比较开启动效与关闭动效两版。二十轮是本文回归样本,不是标准,也不能替代真实用户研究。另加一轮“输入后立即关闭并重开”,确认旧查询和离场回调不会重新打开面板、抢走焦点或清掉新输入。
Direct manipulation:拖动中跟随输入,松手后再吸附
假设附件预览有一个可拖动的说明面板。拖动过程中,面板位置应由当前位移更新;释放后再决定回到原位还是吸附到展开位置。如果用户在吸附途中重新拖动,应从当前可见位置接续,而不是回到旧起点再重播。
实现前,先区分已经确认的位置与本次拖动的临时位置。开始拖动时记住两者;移动时只更新临时位置;释放时根据约定选择目标。新一轮拖动接管当前可见位置以后,上一轮吸附的完成回调就不能再改写它。否则即使每段动画都很顺,用户仍会看到面板被旧任务突然拉走。
下面用同一套状态判断串起说明面板与任务行的删除入口。它是本文建议的最小模型,实际组件可以有不同命名;关键是每个事件对画面和数据分别做什么。
| 当前阶段与输入 | 画面怎样回应 | 数据与取消语义 |
|---|---|---|
| 静止时按下 | 记录当前可见位置,准备接收本次指针输入 | 记录本次操作前的已确认值,不发送删除或保存请求 |
| 拖动中移动 | 根据位移更新临时位置,反馈从开始移动时就出现 | 只改预览;越过视觉阈值不等于已经提交业务动作 |
| 拖动中取消,或按约定按 Esc | 清理本次拖动,回到本次开始前的已确认状态 | 丢弃尚未提交的临时值;不会因此产生删除请求 |
| 正常释放 | 选择目标位置并吸附,或停在约定的最终预览 | 对说明面板可确认展开状态;对任务行仅确认删除入口已展开 |
| 吸附途中再次按下 | 从此刻的可见位置接管,停止旧吸附继续写入 | 新输入拥有这次预览;旧完成回调不能提交或重置它 |
| 明确点击删除、请求处理中 | 保持所操作任务的身份,显示真实处理中状态 | 防止重复提交;关闭面板不等于服务端已经撤销请求 |
| 请求成功或失败 | 成功后更新对应任务;失败时保留可理解的错误与恢复入口 | 只处理仍对应当前任务和当前请求的结果,不把旧失败写到新任务上 |
对于“拖动任务卡片露出删除入口”,可以让拖动只控制预览,真正删除由明确操作触发。把视觉预览、提交条件和服务端结果分别管理,才容易允许反悔和处理取消。需要额外确认还是提供事后撤销,则取决于动作后果与服务端是否支持恢复,不能因为手势看起来轻巧就省去这一判断。
浏览器事件也要有各自的职责。Pointer capture(指针捕获)可以让元素在指针移出边界后继续接收这次指针的事件;pointercancel 则可能表示浏览器接管了滚动、缩放,或本次指针活动被系统中断。它不能当作正常释放后再提交一次。Pointer capture 在 pointerup 或 pointercancel 后还会被隐式释放,因此也不能把每次失去指针捕获都理解成“撤销已经完成的业务动作”。MDN:Pointer events、pointercancel
取消手势和取消请求同样需要分开:如果删除尚未确认,取消可以直接丢弃预览;如果请求已经发出,用户关闭弹层只能说明他不再看这个弹层。没有得到服务端确认,就不能把任务恢复成“已撤销删除”。一个常见反例是把所有退出路径共用同一个重置函数:它既回滚拖动,又把已经发出的请求当作没发生,最终让本地列表与服务器不一致。
无障碍方面,WCAG 2.5.2(A)规定了指针取消的几种合规方式,并非要求每种控件都用同一种确认框。2.5.7(AA)则要求拖动功能有无需拖动的单指针替代,存在拖动必不可少、功能由用户代理提供且未被作者修改等例外。对任务行,可以提供能点击或轻触的菜单与“删除”按钮;只有键盘快捷键,并不能单独满足后一个要求。W3C:Pointer Cancellation、Dragging Movements
验收:拖一半反向、松手后立即再拖、越过阈值后移回,再模拟指针取消。检查画面不跳回旧起点、旧动画不覆盖新状态,也没有未确认的删除请求。需要拖动的功能还应提供不依赖拖动的操作入口。再把删除请求故意延迟,期间关闭面板并打开另一条任务,确认旧响应不会误删新行或把新行标成失败。
触摸与返回:结果可见,任务位置可恢复
假设任务页有音频裁剪滑杆。拇指下的细轨道不适合承担全部反馈,应把当前时间与预览放在手指以外的可见区域。拖动离开轨道后是否继续、取消后恢复什么,都应有明确约定。
反馈可见和选择精度要分别解决。设一段五分钟音频线性铺在 300 CSS 像素的轨道上,本例就是约一秒对应一个像素;即使滑块动画非常平滑,也不能仅靠这段轨道稳定表达更细的选择。若任务只要求大致找到片段,轨道加可见时间读数可能足够;若要校准到十分之一秒,应考虑放大局部时间轴、提供步进按钮或允许输入数值。需要的精度提高以后,变化的应是操作方式,不只是把滑块画得更小。
原生反馈也要看实际遮挡位置。把读数固定放在滑块正下方,可能正好被握持手机的拇指盖住;让读数跟随滑块,又可能在轨道两端跑出容器。可以在上方保留固定读数区,或对跟随提示做边界约束。当前值、临时预览值和已保存值应当有明确关系,否则读数虽然看得清,用户仍不知道取消后会保留哪一个。
手指移出细轨道以后继续追踪,能减少“稍微偏离就丢失操作”的问题,但不意味着整页都应禁止滚动或缩放。应先确定水平调节与页面滚动的分工,并处理浏览器接管输入后的取消。数值输入或可轻触的步进按钮还可以同时服务需要精确值、难以拖动和不使用鼠标的人;它们并不只是拖动失败时的备用装饰。
另一个例子是从筛选后的任务列表进入详情再返回。只恢复页面像素位置可能仍会丢失上下文:筛选变了,或者上方图片加载改变了高度。可以同时恢复搜索词、筛选条件和原条目附近的位置;动态列表可按稳定条目标识定位。
可以把一次返回所需的信息具体记成:查询与排序、已加载到的列表范围、原任务 ID,以及该任务相对滚动容器上边缘的位置。返回时先恢复列表条件,再让相应条目具备可定位的布局,最后恢复位置。虚拟列表还需要先让该条目重新出现;如果直接对旧像素值滚动,可能落在完全不同的任务上。如果任务已经删除或不再符合筛选,本文建议显示仍可解释的相邻内容或列表起点,并说明原任务已不在结果中,不制造一个不存在的目标。
静态页面原本可能由浏览器负责历史导航的滚动恢复。history.scrollRestoration 的 auto 与 manual 控制这一行为;只有在应用准备完整承担恢复流程时,才适合主动选用手动方式。路由代码、浏览器和列表组件各自再滚动一次,容易形成“先回原位、又跳顶部”的竞争。图片迟到后是否重定位、焦点恢复是否又触发滚动,都应由同一条流程协调;如果用户已经开始主动滚动,迟来的恢复操作也不应继续把他拉回去。MDN:History.scrollRestoration
验收:真机左右手各操作一次滑杆,确认数值不被遮挡,并检查键盘替代入口;在列表上方增加内容或完成图片加载后返回,仍能找到原条目。这里的列表恢复方案是本文的产品建议,不是原文滚动地标原型的现成功能。再检查原条目已删除、排序改变、虚拟列表尚未加载与用户提前滚动四种情况,逐项说明最终停在哪里,而不是统一写“恢复成功”。
空间连续:先确认还是同一个对象,再决定怎样移动
附件缩略图展开成预览时,运动可以帮助读者理解正在查看哪一份文件。但这个关系首先由对象身份决定:用户点的是附件 A,预览的标题、内容、返回位置和关闭后的焦点,都应对应 A。把一张外观相似的卡片从原位置放大,随后悄悄换成附件 B,即使运动连续,也会给出错误线索。
在任务列表中,排序或虚拟化会让“第几行”发生变化。可以用稳定的任务或附件 ID 关联预览与来源,开始展开时再读取当前来源的位置。如果图片尚未加载,先保留明确的名称与预览状态;如果来源已经滚出视口,或被删除,就采用普通显示与关闭方式,不强行让窗口飞向一个旧坐标。
展开途中关闭、关闭途中重开,都需要让最新意图接管同一个对象。尤其要避免旧的关闭回调在新预览已经打开后,又把整个预览卸载。这里可以按预览对象与本次操作核对回调是否仍有效,或在接管时清理旧动画;具体机制由所用运行时决定。减少动画时则直接切换到对应状态,仍保留名称、选择与焦点关系。
这一选择会随页面结构改变。照片墙里有很多相似缩略图,来源位置通常值得说明;附件从全文搜索结果打开,而原结果已不在当前页面,制造一个“返回来源”的飞行动画就没有清楚目标。验收时应先指出打开的是谁、关闭后回到哪里,再看运动是否补足了这层关系。
隐式上下文、Fitts’s Law 与可玩性:判断可以省掉哪一步
隐式上下文可以减少重复输入,但应先区分“帮助准备”与“替人确认”。从“待审核”列表打开新建任务时,沿用当前筛选或提供相关模板,可以减少重复选择;把上次的处理人直接设成这次的最终提交对象,则可能沿用了已经失效的意图。本文建议让可推断的值可见、可修改,并让用户刚刚作出的明确选择优先于历史偏好。筛选切换以后,也要检查旧的多选任务是否仍被悄悄带进批量操作。
上下文推断不应以意外跳转来证明自己“聪明”。例如用户只是在选择一个项目类别,界面却立即导航到另一页,未填写的说明也随之丢失。WCAG 3.2.2(A)要求控件设置变化不要自动造成上下文变化,除非事前已告知;这不等于禁止所有局部内容更新。对本文的任务页,可以先更新可见选项与说明,把提交或跳转交给明确的动作。W3C:On Input
Fitts’s Law 提醒人关注目标大小与到达距离,但不能据此推出一个适合所有控件的像素数。对任务行末的三点菜单,通常先扩大实际按钮命中区域,再考虑让操作靠近当前对象;如果每次悬停都让按钮移动、把相邻删除按钮挤到指针下面,较短的距离也可能换来误触。命中区域之间应有清楚边界,焦点轮廓也应对应真实控件。具体最低尺寸与例外仍按第 8 节的标准核验。
也不必因为圆形菜单靠近指针,就把熟悉的文字菜单全部换成手势。固定选项少、重复操作多、方向容易学习时,可以把这类输入作为可评估的方案;动作经常变化、依靠文字区分或需要新用户立即理解时,普通按钮与菜单更容易交代选择。网页内部的容器边缘,也不能直接套用指针无法越过屏幕角落的推理。
可玩性可以放在不会改变任务结果的地方。例如预览区允许手动试动一张示例卡片,用户松手后恢复,不改真实排序;而生产列表里的同样拖动应明确表示排序还是预览,不能让用户先玩一次才知道是否已经保存。保存按钮的回弹也不能要求用户等它结束才能继续操作,更不能每点一次就重新发送请求。这里不推断可玩性能提高专注或学习效果;是否值得保留,要看它有没有干扰当前任务。
动效的关闭能力还有独立要求。WCAG 2.3.3 是 AAA 条款,要求交互触发的非必要运动动画可以禁用;不能误称所有此类条件都是 AA。若移动、闪烁或滚动内容自动开始、持续超过五秒,并与其他内容同时展示,还要按 2.2.2(A)的适用条件提供暂停、停止或隐藏机制,存在必要活动等例外。对任务页的练习,最直接的选择是减少无任务意义的持续运动,并在减少动画模式下保留完整操作与结果。W3C:Animation from Interactions、Pause, Stop, Hide
评审时可以连做一次完整反例:筛选任务后打开命令面板,快速修改查询;拖开一条任务的操作入口后反向取消;打开附件预览,加载完成前关闭并打开另一份;最后返回已经重排的列表。逐步核对“当前是谁、哪一步已确认、哪个结果仍有效”。这样的记录比单独播放几段顺滑动画,更容易暴露跨步骤的错误。
8. 将“手感不错”变成可检查的条件
视觉判断有主观成分,但命中区域、焦点、输入丢失和布局溢出都可以具体检查。任务页交付前,可按下面的顺序进行一次短评审。
| 检查项 | 改变什么输入或条件 | 通过依据 |
|---|---|---|
| 正文 | 真实长段落、中英混排、页面放大 | 文字可读,换行自然,不靠缩小整页解决空间问题 |
| 信息关系 | 暂时去掉装饰颜色 | 分区、状态和主操作仍可识别 |
| 布局 | 长文件名、短文件名、容器变窄 | 文字有明确退让方式,页面不因单行溢出 |
| 层叠 | 页头、菜单、滚动容器同时出现 | 浮层按预期覆盖,无意外裁切,焦点与关闭正常 |
| 表面 | 同屏比较卡片与菜单,更换背景 | 深度层次清楚,边界不依赖一套固定阴影 |
| 输入 | 鼠标、键盘、触摸 | 焦点可见,操作不依赖悬停,命中范围符合预期 |
| 动画 | 快速重复、反向操作、减少动态效果 | 输入不被阻断,状态可中断,减少动画后仍能使用 |
| 结果 | 慢请求、错误响应、重复提交 | 输入反馈与真实结果分开,状态不误导 |
| 返回 | 筛选后的列表、动态内容加载 | 用户能回到原任务上下文 |
尺寸也需要说明依据。比如任务行末的三点图标可以小一些,实际按钮应覆盖足够的空白,而不是只让三个点可以点击。本文按钮从 44px 高度开始练习;WCAG 2.2 的 2.5.8(AA)对指针目标规定的是至少 24 × 24 CSS 像素或满足所列间距等例外条件,不能把 44px 写成该条款的统一门槛。W3C:Target Size (Minimum)
Focus indicator 也不是可选装饰。WCAG 2.4.7(AA)要求键盘可操作的界面存在焦点可见的操作模式;本文示例的 3px 轮廓只是实现选择,不能据此宣称整个页面已经符合所有无障碍要求。W3C:Focus Visible
一次评审最好留下具体记录,例如“长文件名从单行改为换行,图标保持位置”“命令面板出现时立即接收输入”“保存失败后按钮恢复可操作并保留修改内容”。这样的记录能说明改了什么,也便于下一次回归检查。
做一次有输入、有预期的任务页评审
不要先给页面打一个“美观程度”的总分。先选定具体任务:用户从筛选结果进入任务详情,核对附件和费用,修改名称并保存,然后回到列表。把完成这件事需要经过的信息与动作串起来,再观察哪一步让人停顿。这样既能发现“附件看不全”,也能发现“明明保存成功了,却不知道返回后会不会丢失筛选”。
第一轮只评审内容关系。先隐藏不承担含义的装饰色,回答任务是什么、当前状态是什么、费用以什么单位计算、保存与提交是否为不同动作。如果这些答案在短暂扫读后仍不明确,应调整文案、位置和分组,而不是先增加阴影。接着放入第 2 节完整任务页,比较哪一个调整减少了寻找信息的来回移动。
第二轮用边界输入评审约束。把标题换成原来的两倍长度,附件换成连续哈希,费用增加一行“待估算”,再缩窄容器。标题应继续换行,文件名应有明确的完整查看方式,“待估算”不能为了数字对齐被改成 0.00。如果表格确实需要横向阅读,让它在自己的区域滚动,并说明移动端如何看列;不要让一行数据把整个页面拖出屏幕。
第三轮检查状态和接续。运行保存练习,先成功一次,再故意失败;保持失败输入重试,然后在处理中停止等待。分别记录按钮上是什么文字、输入是否仍在、焦点停在哪里。再用文中情景检查旧请求迟到、拖动被取消、预览对象改变和返回目标失效。代码量测能证明盒子尺寸与事件路径,仍不能代替用实际内容完成这些跨步骤任务。
每一轮最好只动一类问题。比如先把标题与费用分成两组,再调整表格密度,最后检查保存反馈。如果一次改了字体、间距、色彩、布局和动画,即使结果看起来更好,也很难知道哪些改动值得保留。
| 本轮假设 | 设计改动 | 用来反驳假设的输入 | 应留下的证据 |
|---|---|---|---|
| 费用难找是因为分组不清 | 让标题靠近表格,统一金额格式 | 插入一段更长的附件说明 | 仍能指出费用区与单位,而非只在原短文案下成立 |
| 附件挤坏布局是自动最小尺寸问题 | 明确文字退让与断行策略 | 连续哈希、短文件名、窄容器 | 实际宽度与溢出结果,并解释完整名称怎样获取 |
| 反馈慢是装饰阻断输入 | 让输入在面板出现时立即可用 | 连续打开、输入、关闭和重开 | 是否丢输入、是否被旧回调改写;服务端等待另行记录 |
| 错误后难以恢复是状态不完整 | 保留输入,提供明确失败结果与重试 | 失败、重试、停止等待后再保存 | 输入、焦点、请求归属与结果文字逐项对应 |
这些记录也决定何时停止润色。正文已能读清、操作关系明确、边界输入可处理以后,一点字距或阴影差异未必值得继续消耗迭代。相反,即使视觉已经统一,只要失败会清空输入、旧请求会覆盖新任务,就仍需要修正。工程质量与视觉判断可以共同工作,各自说明自己的证据。
本文示例的实际核验
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 节的跨步骤场景没有被包装成已实现的拖动应用,也没有执行用户研究。
继续阅读
- 资料形态与免费阅读入口:区分在线书、免费文章与付费课程。
- Frontend 主题导航。
本文是围绕工程页面的实践综合。需要理解交互过程时,建议回到 Josh 和 Rauno 的原网页实际操作示例;排版遇到具体问题时,再按 Practical Typography 的章节查阅。