关于 component-family-consistency
一项用于保持产品交互组件视觉一致性的设计技能,帮助设计师和开发者优先复用或泛化组件,并统一尺寸、设计令牌、边框与状态。
主要功能
- 01新增组件前审查现有组件
- 02建立共享设计令牌
- 03统一控件高度与间距
- 04规范边框和交互状态
- 05测量单个图标的对齐偏差
适用场景
- 01整合重复的按钮和控件
- 02构建一致的表单与组件库
- 03审查产品页面间的视觉偏差
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dembrandt/ dembrandt-skills --skill component-family-consistency安装后在 Claude Code 中输入/component-family-consistency确认已加载。
详细介绍
组件系列一致性
产品中的每个交互组件——按钮、输入框、选择框、复选框、单选按钮、胶囊标签、徽章、标签、日历、日期选择器、滑块、切换开关——都属于同一个视觉系列。它们共享相同的设计基因。用户看到任何一个组件时,都应该觉得它和其他所有组件都属于同一款产品。
如果组件各自独立设计、没有共享令牌,产品就会显得像是由零散部件拼装而成,而非作为一个整体打造。
复用优先于新建组件
创建任何组件之前,先审查已有内容——从头新建组件意味着又多了一个需要维护的对象:系列中又多一个必须保持一致的成员(圆角、尺寸、状态、动效),也多一项维护工作。全新构建应是最后的手段,而不是第一选择。按以下顺序逐步检查:
- 是否已有能实现此功能的组件? 直接使用。如果它几乎符合需求,就通过属性或变体扩展,而不是复制一份——一个灵活的
Button胜过并行存在的PrimaryButton、BigButton和CtaButton。 - 代码库中是否有可以泛化的相似组件? 很多时候,某个组件只为单个页面而内联实现。如果稍作修改就能让它通用——将它提取到共享库中,把硬编码部分参数化(通过属性/令牌设置标签、颜色、尺寸),移除针对特定页面的假设——那就这样做,而不是再写一个几乎相同的组件。
- 只有在没有现有组件适用、也无法合理泛化时,才新建组件——而且新建时,要基于以下共享设计基因构建,让它自然融入整个系列。
多个彼此并行、只做了一次性实现的组件——三个几乎相同的按钮、两个圆角不同的卡片——会让设计系统逐渐偏离。添加组件之前,先问问自己:这个组件已经存在吗?还是只需一次重构就能从现有内容得到?
反过来也一样:如果某种视觉样式独立出现在 2–3 个地方,就值得提炼——在第四处副本出现之前,为它命名,并将其做成共享组件或令牌。不同设计时期的页面样式不一致时,应将旧样式迁移到新样式:最新组件最能体现当前意图,但弃用某种样式前要先确认——整合流程请参阅 generate-ui-from-brand。
自动查找不一致之处(dembrandt 引擎,可选)。 凭肉眼找出线上产品中已经出现的偏差——五种几乎相同的按钮圆角、三个本应统一的灰色——十分繁琐。
get_findings会对真实提取结果运行设计系统检查,并报告需要整合的一致性与重复问题。(而compute_drift会评估两次提取结果之间的偏差程度——例如当前产品与参考版本之间,或清理前后。)在决定复用哪些内容前,可用这些工具审查现有产品。参阅extract-design。
共享设计基因
只需定义一次这些令牌。每个组件都继承它们。
边框圆角
所有交互组件使用相同的基础圆角令牌。不同变体应从中派生,而非自行定义。
--radius-base: 8px; /* buttons, inputs, selects */
--radius-sm: 4px; /* checkboxes, small badges */
--radius-lg: 12px; /* cards, modals, large panels */
--radius-full: 9999px; /* pills, tags, avatar chips */
同一表单中的按钮和输入框必须使用相同的圆角。胶囊标签始终使用 --radius-full。徽章可根据品牌风格使用 --radius-sm 或 --radius-full,但所有徽章必须保持一致。
嵌套圆角应同心。 一个圆角框置于另一个圆角框内时,外层圆角应等于内层圆角加上两者之间的间距。卡片以 12px 内边距包裹 8px 按钮时,需要 20px,而不是 12px。如果设置错误,几像素的距离内,圆角曲率就会不一致——没人会说出问题在哪,但所有人都看得出来。
.card {
padding: var(--space-3); /* 12px */
border-radius: calc(var(--radius-base) + var(--space-3)); /* 8 + 12 = 20px */
}
请使用 calc() 推导,而不是对总和进行硬编码,这样无论哪个令牌发生变化,圆角都能保持正确。
边框样式
所有表单组件和容器的边框都应使用严格受限的一组令牌。
两步规则: 边框宽度最多限制为两种选项(例如 1px 和 4px,或 1px 和 8px)。不要使用 1px, 2px, 3px, 4px... 这样的递增刻度。选项有限,能让层级一目了然,也让产品显得经过精心设计。
--border-width-thin: 1px; /* Default for inputs, cards, dividers */
--border-width-thick: 4px; /* Featured items, bold accents, active indicators */
--border-color: var(--color-border);
--border-color-focus: var(--color-primary);
--border-color-error: var(--color-error);
输入框和选择框在默认状态下的边框应完全一致。所有组件的焦点状态都使用 --border-color-focus。错误状态都使用 --border-color-error。
间距和高度
处于同一视觉尺度的组件应保持相同高度和内部内边距。
/* Default (md) size */
--component-height-md: 2.5rem; /* 40px */
--component-padding-x-md: 0.75rem; /* 12px */
--component-padding-y-md: 0.5rem; /* 8px */
/* Small */
--component-height-sm: 2rem; /* 32px */
--component-padding-x-sm: 0.5rem; /* 8px */
--component-padding-y-sm: 0.375rem; /* 6px */
/* Large */
--component-height-lg: 3rem; /* 48px */
--component-padding-x-lg: 1rem; /* 16px */
--component-padding-y-lg: 0.625rem; /* 10px */
高度和文本内边距使用 rem,这样即使用户放大文本,控件仍能容纳标签;边框和阴影则保持使用 px。参见 [[sizing-units]]。
并排放置的按钮和输入框必须高度相同。这不是外观上的小事——高度不匹配会破坏表单布局,也会显得杂乱无章。
设定高度,不要推算高度。 仅由内边距决定尺寸的控件,其高度为行高 + 内边距 + 边框。因此,只要这三者中任何一项不同,同一行的两个控件就会出现高度偏差:
- 带轮廓的变体比无边框的变体高 2px;
- 流式或限制范围的字号会在某些视口下改变行框,在其他视口下则不会;
- 内容是头像或图标而非文本的项目,其固有高度会有所不同。
为同一行中的每个控件指定相同的显式高度,并将内容居中。将其设为 min-height,这样控件在默认状态下能保持形状;内容超出预期时也能扩展,而不会被裁切。
从盒模型角度论证,而非纠结像素。 边框是否计入高度取决于 box-sizing。明确这一点才能结束讨论——如果争论总围绕细微的像素调整,就永远无法达成结论,因为每个人描述的都是同一个声明导致的不同症状。在 content-box 下,边框会加到高度上,因此带轮廓的控件比其幽灵变体高 2px,居中后也会比它偏移 1px。在 Tailwind 和大多数重置样式中的默认设置 box-sizing: border-box 下,边框会计入高度,因此两个盒子尺寸相同,变体即使增加或移除边框也不会引起位移。
border-box 只是将问题移到内部,并没有消除问题:边框现在会占用顶部和底部各 1px 的内部空间,因此内容未居中的控件,或内部剩余像素数为奇数的控件,仍会因取整而向一侧偏移。这就是那个无法通过搜索定位的 1px 问题。解决办法是始终让边框留在盒子中——为无边框变体设置 border: 1px solid transparent——这样无论是否绘制边框,内部空间都完全一致。
推算得出的高度也是一种无法搜索定位的缺陷。 显式高度是一个可以 grep 和比较差异的令牌。推算高度则是三个独立声明共同产生的结果,因此一行中的八个控件可能只有一个不正确,却没有任何查询能找到它:在使用工具类的代码库中,相同的内边距会以不同顺序出现(rounded-md px-3 py-2 和 rounded-md border transition-colors px-3 py-2),查找替换只能修复其中一部分,同时悄悄漏掉其他部分。每处遗漏都是 2px,单独看不明显,也是你“修好”后整行看起来仍然不对的原因。
唯一做法:由一个类持有整行样式
在同级元素中重复相同的值会导致整行样式逐渐偏离,因为之后每次修改都得找到每一份副本。改为只声明一次。每行中的所有项目都使用同一个共享类;该类负责盒模型、内容区域和所有状态;实例只能设置颜色,不能设置其他内容。
.control {
box-sizing: border-box;
height: var(--control-h); /* set, never derived */
display: inline-flex;
align-items: center;
gap: var(--control-gap);
padding-inline: var(--control-px); /* including its responsive steps */
border: 1px solid transparent; /* borderless variants keep the border, transparent */
border-radius: var(--radius-base);
font-size: var(--control-font); /* declared here, not on the label inside */
cursor: pointer; /* browsers give `button` cursor: default */
}
/* Opt-in, not `.control > *`: a descendant selector reaches icons that set
their own dimensions and stretches them. One content slot for every child
that carries content: avatar, label, icon, count. Equal boxes do
not make an equal row, and a control holding an avatar next to one holding a
text line looks uneven precisely when both measure identical. Standardising
the slot is not drawing everything at one size: inside a 20px slot an icon
can be 16px and a chevron 12px and the row still reads level. */
.control-slot { height: var(--control-slot); display: inline-flex; align-items: center; }
.control:hover { /* one definition for the whole family */ }
以下三条规则可以确保这一点:
- 整行中的任何元素都不能自行设置高度、内边距、字号、圆角或状态。 如果某个控件需要类中没有的样式,就为该类添加一个变体。
- 变体只能改变颜色,不能改变其他内容。 变体一旦涉及盒模型,它就成了一个冒充单一类的第二个类。
- 状态只能在类上修改,不能只改某个实例。 这才是真正会发生的回归问题:盒子最初都构建正确,但几个月后,某个同级元素有了新的悬停效果、新的焦点环或新的过渡效果,整行就此分裂。一行有八个控件,其生命周期中盒模型只需修改一次,状态却要修改几十次,所以状态规则最能发挥作用。
图标的视觉重心不在其盒子的中心
align-items: center 会将图标的盒子居中。盒子里的绘制形状通常并未居中,因此,尺寸看起来水平对齐的图标,放在标签旁边时却显得偏低或偏高。星形的视觉重心偏向顶部,下载箭头的视觉重心偏向底部。应为每个图标单独设置偏移,不要在一组图标间共用一个微调值:不同形状需要不同的修正值,共用一个值必然会让某个图标偏过头、另一个偏不到位。两个提交对同一行进行了方向相反的调整,这正是共用常量的表现,并不意味着其中一个调整错了。
不要凭感觉不断微调,而要进行测量。将字形栅格化,计算 alpha 通道的质心,再与其将要居中的盒子中心进行比较:
from PIL import Image
a = Image.open("icon.png").convert("RGBA").split()[3] # alpha = ink coverage
px, w, h = a.load(), *a.size
tot = sum(px[x, y] for y in range(h) for x in range(w))
cy = sum(px[x, y] * y for y in range(h) for x in range(w)) / tot
offset_px = (h / 2 - cy) / (h / RENDER_PX) # negative: lift the icon
将结果作为每个图标单独的偏移写入标记中,在注释中注明测量值,并在替换图标时重新测量。以下两种做法听起来有道理,实际却不然:
- 先模糊处理通常不会改变结果。 估算眼睛如何综合感知视觉重心是合理的直觉,但对于紧凑的实心字形,高斯模糊只会让质心移动百分之几像素。只有对带有细长延伸部分的形状,这种做法才有意义;这些延伸部分会影响边界框的权重,却不会同等影响视觉感受。
- 颜色不会移动质心。 对于单色字形,亮度会按相同的常数缩放每个像素,因此重心保持不变。颜色会改变图标相对于文本的视觉重量,这是尺寸和粗细的选择,而不是对齐问题。
同样的道理也适用于用作标记的单个字母:偏移取决于字母形状的视觉重心,而且不同字母的偏移方向确实可能相反。
光标也属于类的职责。 在所有浏览器中,<button> 的默认光标都是 cursor: default,框架重置样式也未必会修正这一点:Tailwind v4 的 preflight 就不会。光标是指针用户能获得的成本最低的交互提示,也是任何悬停颜色出现前最先传达信息的提示,因此,看起来可点击却仍显示箭头光标的控件会显得无法交互。这个问题恰恰容易通过评审,因为悬停状态通常已经实现,只有光标不正确。请核实而非想当然,因为不同主版本的 preflight 内容会变化:grep -n "cursor" node_modules/tailwindcss/preflight.css。如果用不具备原生交互能力的标签创建交互元素(<div role="button">),就需要设置光标、焦点样式和按键处理;光标只是其中最浅显的一部分。
这些控件首尾相连时,圆角应应用于整个组,而不是每个子元素:外侧圆角保留 --radius-base,内侧接合处改为直角,这样整条控件栏看起来才像一个整体。也要将共享边框合并为单条线——两个相邻的 1px 边框会渲染成 2px 的接缝,而该系列中的其他控件都不会有这样的接缝。
当控件展开时,同样的逻辑会沿垂直方向延续。下拉选择框、多选框、日期选择器或组合框,以及它展开的面板,构成一个接缝:触发器顶部保留该系列的圆角,底部改为直角;面板顶部改为直角,底部保留该系列的圆角,这样展开后的控件看起来是一个更高的整体,而不是两个上下堆叠的形状。接缝处无论是细分隔线、小箭头缺口,还是完全没有装饰,都不会改变圆角。面板的边框应延续触发器的边框,宽度和颜色保持一致;控件展开时,触发器的高度仍为 --control-h。
对于在同一个共享表面上组合多个控件的组(例如头像旁的余额、分段控件、附带按钮的输入框),应在包装元素上固定高度,并同时将该高度设到子元素上。单靠拉伸实现高度一致,只是布局的副作用,之后改动 align-items 或添加绝对定位的子元素就会悄然破坏这一效果。
引入这个类是最危险的一步,具体会以两种方式出错。 这两种问题在代码审查中都不易察觉,在屏幕上却一目了然:
它必须优先级低于与之并列的实用工具类。 在实用工具优先的代码库中,每个实例仍然带有颜色类,而在框架之后加载的普通样式表会覆盖它们。应将该类放入框架的组件层(@layer components,或等效的 @layer 顺序),并使用长属性,绝不要使用简写属性:border: 1px solid transparent 会将所有按钮的边框颜色重置为透明,因为这个简写属性会重置实用工具类设置的 border-color。background、padding、font 和 transition 也有同样的陷阱。
它必须使用现有令牌,而不是重述令牌的值。 将旧标记中的 8px 复制为字面值会造成令牌分叉:这一行现在显示正确,但令牌下次变更时就不会再跟随设计系统。应引用 var(--radius-md)、var(--control-h) 等;如果所需值没有对应的令牌,就新增一个。
引入之后,重新测量。类本身可能正确,但实际效果仍然不对;测量只需几秒。
测量,不要推测。 错误的盒子、盒子正确但内容视觉重量不匹配,以及被表面样式破坏的正确行,都会让人觉得“高度不对”,因此在它们之间猜测只会修错问题。使用无头浏览器,在应用实际编译后的样式表下,以两个视口宽度渲染该行,并读取每个控件:
[...document.querySelectorAll('[data-control]')].map(el => {
const r = el.getBoundingClientRect(), c = getComputedStyle(el);
return { h: r.height, top: r.top, fontSize: c.fontSize, padLeft: c.paddingLeft };
});
height 相同证明盒子尺寸一致;top 相同证明它们共用基线;fontSize 或 padLeft 不同则说明范式不匹配——用数字说明,而不是争论。
检查构建该行的每个外壳。 同一工具栏通常会在多个地方组装,而屏幕上显示的可能并不是你打开的那个。修改后没有任何可见变化,说明你编辑了错误的文件,而不是修复本身有问题。
在一排按钮旁分组放置非按钮元素
工具栏通常需要容纳非按钮元素:余额、状态、头像、计数。将它们随意放进一排按钮中,会让它们看起来像是坏掉的按钮。解决方法是使用共同区域(参见 [[gestalt-ui-organisation]])——用一个表面传达“这些元素属于一起,但与那些元素不同”——由表面而非元素本身承担分组作用。
绘制该区域的方式,按视觉干扰由弱到强排列:
| 样式 | 视觉效果 | 适用场景 |
|---|---|---|
| 背景色调(2–8% 中性色) | 静止的表面 | 默认选择。足够低调,可与描边按钮并排而不争抢注意力 |
| 边框 | 容器 | 这一排已有无边框按钮,因此边框仍能起到区分作用 |
| 内阴影 | 凹陷的井状区域 | 该组是类似输入框或显示区域的区域,而非一组操作 |
| 渐变 | 隆起的实体感 | 少用。它会重新引入你试图移除的按钮暗示 |
无论选择哪种方式:
- 与按钮的
border-radius完全一致。 若圆角不同,并排时会显得格格不入,而不是同组元素。 - 高度保持一致。 同上,该组应占据一个控件的垂直空间。
- 只选择一种样式。 色调加边框再加内阴影,是用三种方式表达同一件事;在工具栏的尺寸下,元素无法承受这样的视觉重量(参见“小型组件的克制”)。
- 子元素保持简洁。 不要为它们添加填充或边框——分组已经由区域完成。它们唯一的状态是悬停,且效果比静止表面略明显。
- 不要让子元素的悬停状态重绘整个分组。 为外层包装元素设置独立的边框和分隔线颜色,并保持静态。如果子元素复用独立按钮样式,悬停单个区段时就会重绘整个单元的轮廓,让分组看起来像在抖动。
- 每组放一个图标,而不是每个区段放一个。 三个区段都带有相同的下载图标,只是把图标重复了三次,并没有变成三个带标签的选项。只有在标签无法显示时才显示图标,例如窄屏下。
方向承载含义。 隆起和凹陷是同样的两种效果,只是方向相反,因此传达的承诺也相反——可按压,还是可阅读。凹陷表面需要具备全部三个部分:渐变从上到下由深变浅、顶部边缘有内阴影,以及底部边缘有一条浅色细线来封住凹陷。省略最后一项,顶部阴影看起来就像污渍,而非深度。若将前两项反转,你就重现了原本想避免的从上方受光的按钮。
/* Recessed: a readout. */
background-image: linear-gradient(180deg, rgba(0,0,0,0.06), rgba(0,0,0,0.02));
box-shadow: inset 0 1px 2px rgba(0,0,0,0.07), inset 0 -1px 0 rgba(255,255,255,0.6);
分别调整两种主题——在浅色主题下看起来像细微凹陷的相同透明度值,在深色主题下可能显得平坦或像一片污迹。
阴影
交互式组件使用一致的阴影规则:
- 静止时:无阴影;浮动组件(选择框下拉触发器)使用
--shadow-xs - 聚焦时:通过
outline显示焦点环,而非box-shadow(若始终使用box-shadow作为焦点环则除外) - 悬浮层(由组件打开的下拉菜单、弹出框):使用
--shadow-md
颜色规则
所有组件统一应用相同的颜色角色:
| 状态 | 颜色令牌 |
|---|---|
| 静止边框 | --color-border |
| 聚焦边框 / 焦点环 | --color-primary |
| 错误边框 | --color-error |
| 禁用 | 降低不透明度的 --color-text-secondary |
| 已选 / 激活填充 | --color-primary |
| 悬停背景 | 不透明度为 8–12% 的 --color-primary |
统一的交互语言
上方的颜色表定义了每种状态看起来是什么样;本规则规定产品允许有多少种交互模式。选一种并反复使用——不要在同一产品中混用 3–4 种不同的悬停/激活/交互模式。
用户已经知道自己在哪个网站上。产品之间有所不同是意料之中的事;同一产品内部变化太多则会增加负担——每种新模式都是用户在任务过程中需要学习的新东西。因此,应当保持一致:
- 一种悬停响应。 如果交互元素在悬停时上移,就让它们都上移;如果它们会改变背景色调,就让它们都改变色调。不要在同级组件中混用上移、下划线、颜色切换和缩放。
- 一种激活/按下响应、一种焦点环、一种选中样式——并在所有地方以完全一致的方式应用(这就是为什么上方的颜色角色是共享令牌,而不是针对各组件分别选择的)。
- 一种动效特征——同类过渡使用相同的缓动和时长,让悬停、显示和切换都像出自同一只手(参见
micro-interactions)。
紧凑且反复使用的交互词汇能让产品更容易上手:用户学会一次,就能在任何地方信任并使用这种模式。
组件家族成员
| 组件 | 半径一致 | 高度一致 | 边框一致 | 颜色逻辑一致 |
|---|---|---|---|---|
| 按钮 | ✓ | ✓ | —(实心) | ✓ |
| 输入框/文本区域 | ✓ | ✓ | ✓ | ✓ |
| 选择框 | ✓ | ✓ | ✓ | ✓ |
| 复选框 | --radius-sm | — | ✓ | ✓ |
| 单选按钮 | --radius-full | — | ✓ | ✓ |
| 切换开关 | --radius-full | ✓ | — | ✓ |
| 胶囊标签/标签 | --radius-full | ✓ | 可选 ✓ | ✓ |
| 徽标 | --radius-sm 或 --radius-full | — | — | ✓ |
| 日期选择器/日历 | --radius-base | ✓ | ✓ | ✓ |
| 滑块 | --radius-full(轨道 + 滑块) | — | — | ✓ |
| 分段控件/按钮栏 | ✓ 仅外侧圆角 | ✓ | ✓ 共享 | ✓ |
| 搜索框 | ✓ | ✓ | ✓ | ✓ |
| 组合框 | ✓ | ✓ | ✓ | ✓ |
家族相似性,各司其职
共享的设计基因让组件看起来彼此相关,但不能让它们看起来可以互换。最容易出问题的是那些共享特征最多的组合:胶囊形按钮旁边放着胶囊形徽标,带边框的按钮旁边放着带边框的输入框。它们一旦难以区分,用户就会点击没有任何作用的徽标,并错过看起来像标签的按钮。
规则是:交互之前就必须能看出组件的作用。 仅凭外观,用户就能分辨哪些可以点击、哪些可以编辑、哪些是只读的。
- 按钮——可点击:实心填充或明显的边框、
cursor: pointer、悬停响应。 - 徽标/标签——只读:低调的填充、较小的字体、绝不添加悬停响应或指针光标——这两种信号专用于交互元素,也正是它们能将徽标与同形状的按钮区分开来的关键。
- 输入框——可编辑:带边框且内部为空、有占位符、文本光标。
输入框和按钮并排放置,是最容易出问题的一组。 两者都是高度相同、带 1px 边框的圆角框;一旦按钮使用幽灵或描边样式,它们看起来就完全一样。保持高度一致——这体现了家族特征——并通过内部设计体现差异:输入框保持为空并显示占位符,光标为文本光标;按钮则使用填充或明显的边框、采用按钮字重的标签,以及指针光标。如果区分它们的唯一依据是其中一个有文字,那么这个表单只要少一个占位符就会变得模糊不清。
至少通过两个视觉维度来区分(填充 + 尺寸、边框 + 光标)——绝不能只靠颜色,也绝不能靠高度。眯眼测试:看不清标签时,你还能把按钮、徽标和输入框区分开吗?如果不能,这个组件家族就已经塌缩成了同一种组件。
语义芯片组件
通用的 Badge 组件容易被误用——同一个组件最终被用于状态、代码词元、键盘快捷键和类别标签,样式覆盖则散落在整个代码库中。
这种模式是:每种含义对应一个组件,而不是一个组件配备多种变体。
产品中的行内标签通常只涉及少数几种不同的含义。为每种含义定义一个组件。常见示例:
| 组件 | 含义 | 形状 |
|---|---|---|
Tag | 类别标签、状态、筛选条件 | 药丸形(rounded-full) |
Code | 行内字面量、路径、键 | <code>、等宽字体、较小圆角 |
Kbd | 键盘快捷键 | <kbd>、等宽字体、较小圆角 |
Metric | 测量值(1.2s、42px) | 等宽字体、较小圆角 |
按需添加产品专属类型(例如,CLI 产品中的 Flag、API 产品中的 Token)。每种新类型都应有自己的组件,而不是给现有组件添加新的 variant 属性。
每个组件只体现一种含义。外观由此决定——调用方无需传入颜色或形状属性。
尺寸: 使用相对于 em 的内边距,让芯片无论处于哪种文本环境(标题、正文、说明文字)都能呈现恰当尺寸,无需针对不同环境进行覆盖。
const BASE = "inline-flex items-center align-middle whitespace-nowrap border leading-none";
const PILL = "text-[0.85em] px-[0.6em] py-[0.25em] rounded-full font-medium";
const CHIP = "text-[0.85em] px-[0.5em] py-[0.2em] rounded-[0.4em] font-mono";
export function Tag({ children }: { children: ReactNode }) {
return <span className={`chip-tag ${BASE} ${PILL}`}>{children}</span>;
}
export function Code({ children }: { children: ReactNode }) {
return <code className={`chip-code ${BASE} ${CHIP}`}>{children}</code>;
}
export function Kbd({ children }: { children: ReactNode }) {
return <kbd className={`chip-kbd ${BASE} ${CHIP}`}>{children}</kbd>;
}
颜色: 在一个文件中通过 CSS 类(chip-tag、chip-code 等)为每种语义设置颜色。不要内联传入颜色属性。这样可将浅色/深色模式样式集中在一处,也便于一眼检查完整的芯片调色板。
.chip-tag, .chip-code, .chip-kbd, .chip-metric {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-secondary);
}
.chip-tag { color: var(--text-primary); }
向后兼容: 如果现有调用点使用通用的 Badge,则将最常用的语义变体重新导出为默认项,以便旧的导入方式无需迁移也能继续使用。
行内对齐
当一行中混合多种元素类型——标签、徽章、状态圆点、数值、图标——它们必须看起来像一行整齐对齐的内容,而不是一堆大小各异的元素。
- 同一行中的字号一致。 徽章或芯片旁的文本应与周围文本字号相同;徽章不得悄悄缩小或放大所在行。使用
leading-none和align-middle(如上面的芯片BASE),让所有元素位于同一条中心线上。 - 状态指示器居中。 红/黄/绿的交通灯圆点应相对于其标注的文本垂直居中——与标签大写字母的高度中心对齐,而不是与基线对齐。
- 文本旁的徽章要与文本对齐。 将其与基线对齐,或让它在视觉上与字母 x 高度居中。对称地增加徽章内边距,使其与旁边的元素保持居中,而不只是自身内部居中。
- 共用一条视觉中心线。 如果徽章、文本和图标上下跳动,即使每个元素单独看都没问题,这一行整体看起来仍会支离破碎。
计数徽章应保持圆形
使用 border-box 为计数徽章设置尺寸,并将高度和 min-width 设为相同值,同时只添加水平内边距,这样出现第二位数字时徽章就会加宽。不要给已经带有侧边内边距和边框的通用药丸形徽章设置固定宽度:content-box 会把两者都加进去,导致一个 20px 的圆形渲染成 42px 的药丸形,看起来像标签。
小型组件的克制设计
组件越小,能承载的内容就越少。在大尺寸下看似朴素的克制设计,才能让小型组件保持清晰易读。
- 避免多层边框/盒中盒容器。 不要叠加带边框的层级(带边框的标签嵌在带边框的单元格中,单元格又嵌在带边框的卡片中)——小元素承受不了这样的视觉负担。优先使用一道边框或不加边框;改用填充色或间距。此规则是「2 步边框规则」在小尺寸场景下的补充。
- 图标最多一个。 在胶囊、徽章或行中放两三个图标会显得杂乱,也让人难以判断哪个图标可操作。如果需要更多图标,说明组件的尺寸已经不够——应将其提升为更大的模式。
- 多卡片容器设计 → 改用其他方案。 卡片套卡片再套卡片,是用方框而非间距和层级来解决分组问题。将其扁平化,用留白和标题分组(参见 [[gestalt-ui-organisation]]),并且只在最外层、有实际意义的容器中使用卡片样式。
如果品牌使用渐变,应保持一致:
- 主要按钮上的渐变应与产品其他位置使用的渐变采用相同的角度和色标
- 悬停状态:稍微调整渐变的亮度,不要改变色相
- 不要在某些按钮变体上使用渐变,在其他变体上使用纯色——为每种变体选定一种方案,并在所有场景中统一使用
复用产品自身的标记
新视图需要用标记表示状态(相同、已更改、已添加、已移除)时,应查找并准确复用产品已有的标记。为同一概念引入第二套术语,会让读者同时学习两套表达,也会与其他地方已经固定的含义相矛盾。
检查清单
- 同一表单中的按钮和输入框高度是否一致?
- 是否显式设置了该高度,而不是依靠内边距、行高和边框累加得出?
- 一行中的所有控件是否都来自同一个共享类,而不是重复设置相同的值?
- 该类是否位于组件层并使用长属性,以便实例级工具类仍能覆盖它?
- 该类是否引用现有的圆角、高度和间距 token,而不是复制它们的值?
- 控件的每个子元素(头像、标签、图标)是否都位于同一内容槽高度内?
- 所有状态是否都定义在该类上,以免新增的悬停或焦点环只应用到其中一个同级元素?
- 是否使用真实样式表在多个视口下测量过高度,而不是仅凭推断?
- 所有带边框的组件是否最多只使用两种边框宽度(例如 1px 和 4px)?
- 所有可聚焦组件的焦点状态是否完全一致?
- 所有可能出现错误的组件,其错误状态是否完全一致?
- 是否采用统一的交互语言——一种悬停反馈、一种激活/按下反馈、一种焦点环、一种动效特征——并在整个产品中复用,而不是并存 3–4 种相互竞争的模式?
- 所有圆角值是否都基于同一个基础 token 派生,而不是由各组件分别设置?
- 胶囊和标签是否都一致使用
--radius-full? - 是否仅凭外观就能区分按钮、徽章和输入框(眯眼测试)——且非交互元素没有悬停反馈或指针光标?
- 渐变的使用(如有)是否在所有按钮变体中保持一致?
- 构建新组件之前,是否检查过现有组件库(和代码库),确认没有可直接适用的组件,或只需稍作改动即可通用化的临时组件,而不是直接克隆?
- 能否仅使用现有 token 将新组件添加到组件库?
- 行内标签、状态、代码 token、键盘提示和指标是否都是独立组件,而非通用 Badge 的变体?
- Chip/Badge 组件是否使用基于
em的尺寸,以便随所在文本环境缩放? - Chip 颜色是否定义在 CSS 类中(而非行内属性中),以便明暗主题规则集中在一处?
- 在混排文本、徽章和图标的行中,所有元素是否使用统一的字号并共享中心线?
- 状态/交通灯指示器是否与其标签垂直居中?
- 小型组件是否避免堆叠/嵌套边框(盒中盒的视觉效果)?
- 小型组件是否最多只包含一个图标?
- 是否通过间距和层级展平卡片套卡片的嵌套结构?
- 新视图是否复用了产品现有的状态标记和图标,而没有自行发明新的标记和图标?