设计系统
系统就是产物。其他一切皆构建于其上。一个自行发明颜色和间距的可交付物是一次性的;基于令牌构建的可交付物可以扩展、换肤并保持一致。
输出
三个文件,其分割本身就是关键所在:
design/tokens.css—— 仅包含令牌,无其他内容。纯粹的 CSS 自定义属性。design/components.css—— 组件,作为仅从语义化令牌构建的类。design/Design System.html—— 样张。它<link>引用了两个文件并将其记录。仅作文档:它不声明自己的任何值。
这两个 CSS 文件才是系统:其他应用将复制它们。样张保留在项目中作为其文档。
样张中的章节顺序如下:
- 目标 —— 三句话:为谁设计,应该带来什么感觉,明确不是什么。
- 颜色 —— 每个色板及其令牌名称、解析出的值和通过对比度测试的组合。
- 字体 —— 字体族、字重、为每个步进分配命名角色的缩放,以及正文大小的段落示例。
- 间距和形状 —— 间距缩放、圆角、阴影、边框。
- 组件 —— 每个组件以其真实状态渲染,旁边是应用复制的标记。
- 规则 —— 其他人可能会出错的五六个决策。
- 在应用中使用 —— 版本、需复制的两个文件、加载顺序、字体
<link>、根类以及自上一版本以来的变更。
令牌
两层结构。原始值持有原始值;语义化令牌命名一个职责并指向一个原始值。组件只引用语义化令牌——这就是让换肤成为一行变更的关键所在。
写入design/tokens.css。这是项目中唯一可以出现原始值的文件。acme代表系统前缀(参见共享):
/* Acme 设计系统 1.0.0 — 令牌。照原样复制此文件;在其源项目中修改。 */
:root {
/* 原始值 */
--acme-ink-900: oklch(0.22 0.02 260);
--acme-ink-600: oklch(0.48 0.02 260);
--acme-paper-50: oklch(0.99 0.005 90);
--acme-paper-100: oklch(0.96 0.008 90);
--acme-line-200: oklch(0.90 0.006 90);
--acme-accent-500: oklch(0.55 0.14 35);
--acme-accent-600: oklch(0.45 0.15 35);
--acme-accent-100: oklch(0.94 0.03 35);
/* 语义化 */
--acme-text: var(--acme-ink-900);
--acme-text-muted: var(--acme-ink-600);
--acme-bg: var(--acme-paper-50);
--acme-surface: var(--acme-paper-100);
--acme-border: var(--acme-line-200);
--acme-accent: var(--acme-accent-500);
--acme-accent-hover: var(--acme-accent-600);
--acme-accent-wash: var(--acme-accent-100);
/* 字体 */
--acme-font-display: "Fraunces", Georgia, serif;
--acme-font-text: "Public Sans", system-ui, sans-serif;
--acme-step--1: 0.875rem;
--acme-step-0: 1rem;
--acme-step-1: 1.25rem;
--acme-step-2: 1.5625rem;
--acme-step-3: 1.953rem;
--acme-step-4: 2.441rem;
--acme-step-5: 3.052rem;
/* 间距,基于 4px 基准 */
--acme-s-1: 4px; --acme-s-2: 8px; --acme-s-3: 12px; --acme-s-4: 16px;
--acme-s-5: 24px; --acme-s-6: 32px; --acme-s-7: 48px; --acme-s-8: 64px; --acme-s-9: 96px;
/* 形状和动效 */
--acme-radius-sm: 4px; --acme-radius: 8px; --acme-radius-lg: 16px;
--acme-border-width: 1px; --acme-ring-width: 2px;
--acme-shadow-1: 0 1px 2px oklch(0.2 0.02 260 / 0.06);
--acme-shadow-2: 0 4px 16px oklch(0.2 0.02 260 / 0.08);
--acme-ease: cubic-bezier(0.2, 0.7, 0.3, 1);
--acme-dur: 160ms;
}
该代码块是一个起点,而非盲目复制的模板。根据需求调整强调色相、字体组合和墨水色调——但保持结构不变。
令牌规则
- 对比度需测量,而非目测。 正文相对于实际背景需达到 4.5:1;大文本和 UI 边框需达到 3:1。每个色板页面需说明其组合。
- 使用 Oklch,而非随意的十六进制。 均匀的明度步进可避免调色板显得浑浊。保持同一色相家族内的色度大致恒定。
- 无未使用的令牌。 如果没有引用某个令牌,则将其删除。
- 此文件外无原始值。 任何在其他地方出现的十六进制、
oklch()字面量、像素大小或阴影都是一个尚未被命名的令牌。 - 两种字体家族。 一种展示字体,一种正文字体。后备字体栈必须真实,不能仅仅是
sans-serif。 - 使用缩放,不要即兴发挥。 如果一个值不在间距或字体缩放范围之内,要么有意添加它,要么使用最接近的值。
- 仅当被要求时才实现暗色模式 —— 但保持颜色语义化,以便后续添加时只需替换令牌,而非重写。
组件
将它们写入 design/components.css,并在样张中每个组件的默认、悬停、聚焦、激活和禁用状态下进行渲染:
- 按钮 —— 主要、次要、幽灵。通常一种尺寸就够了。
- 文本输入和选择框,附带标签、帮助文本和错误状态。
- 卡片,使用真实的内容示例,而非“Lorem ipsum”。
- 导航,在桌面端和窄屏宽度下。
- 表格或列表行,带有表头。
- 徽章和标签。
- 模态框或对话框。
- 空状态。
- 提示条或内联通知。
每个组件仅使用语义化令牌。如果某个组件需要没有对应令牌的值,那就是系统的一个缺口 —— 添加令牌,而不是硬编码值。
/* Acme 设计系统 1.0.0 — 组件。需要先加载同一版本的 tokens.css。 */
.acme {
font-family: var(--acme-font-text);
font-size: var(--acme-step-0);
color: var(--acme-text);
background: var(--acme-bg);
}
.acme-button {
font: inherit;
padding: var(--acme-s-2) var(--acme-s-4);
border: var(--acme-border-width) solid transparent;
border-radius: var(--acme-radius);
background: var(--acme-accent);
color: var(--acme-bg);
transition: background var(--acme-dur) var(--acme-ease);
}
.acme-button:hover { background: var(--acme-accent-hover); }
.acme-button:focus-visible { outline: var(--acme-ring-width) solid var(--acme-accent); outline-offset: var(--acme-ring-width); }
.acme-button--ghost { background: transparent; color: var(--acme-accent); border-color: var(--acme-border); }
共享
其他应用通过复制tokens.css和components.css来获取系统。以下规则确保复制件在任何地方都能工作。
- 所有内容加前缀。 在第一个令牌之前为系统选择一个简短名称,并将其应用于每个自定义属性和每个类:
--acme-accent、.acme-button。复制件可以与应用自身的 CSS 共存而不会冲突。前缀是 API 的一部分;切勿更改。 - 两个文件均使用纯 CSS。 无 Tailwind 指令、无预处理器语法、无嵌套、无
@layer要求、两者之间无@import。React 应用、静态页面或其他设计工具必须通过直接复制获得相同结果。 - 一切在运行时解析。 无构建时的颜色计算,无编译输出。
components.css中无全局样式。 无重置、无元素选择器、无针对html或body的样式:将文件放入应用不应改变其样式。基本文本样式存在于根类.acme上,应用可将其添加到需要的位置。- 状态来自标记。 使用
:hover、:focus-visible、:disabled、[aria-invalid="true"]、[aria-current]和[aria-expanded]进行样式设置,而非使用应用需要切换的状态类,以便组件能与任何框架的 HTML 配合使用。 - 易于覆盖。 尽可能每条规则只用一个类,无 ID,无
!important。 - 两个文件版本一致。 每个文件的第一行标明系统及其版本;每次变更时同时提升版本。值变更打补丁,新增令牌或组件打次版本,删除打主版本。更名时添加新令牌并将旧令牌保留为别名
--acme-old: var(--acme-new);,直到应用迁移完毕;移除别名即为主版本升级。 - 项目相关内容留在样张中。 字体
<link>标签、容器宽度和资源路径不是令牌。样张展示字体<link>,以便应用可以随文件一起复制。
如果目标项目已有令牌文件,请先匹配其命名规则,再自行发明。一个共享的词汇表比一个更好的名称更有价值。
样张页面
样张是给人类看的文档,而非供欣赏的组件画廊。在色板旁边显示令牌名称。显示每个组件及其应用要复制的标记。注明难以看到的状态(聚焦环、禁用)。
链接两个文件,令牌优先,并将根类放在 <body> 上。使用系统自身的字体和间距来布局样张 —— 这是系统能正常工作的第一个证明。
从文件中读取值,而非手动输入,这样文档就不会偏离:
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="components.css">
…
<body class="acme">
<figure class="swatch" data-token="--acme-accent-500"></figure>
…
<script>
for (const el of document.querySelectorAll('[data-token]')) {
const name = el.dataset.token;
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
el.dataset.value = value; // 由 CSS 打印
el.style.setProperty('--swatch', value); // 由 CSS 绘制
}
</script>
色板根据令牌自绘并打印出浏览器解析后的值。手动输入的十六进制是未来的谎言。
完成
在样张上运行 design-verify。它会列出样张或 components.css 中的任何颜色字面量;应该没有。检查每个色板标签是否清晰可读,每个组件是否与同一基线网格对齐,以及是否存在示例文本在 1024px 宽度下溢出其容器的情况。
当系统迁移到其他应用时,它直接复制 tokens.css 和 components.css,加上样张中的字体 <link> —— 而非样张本身。仅在需要在没有文件夹的位置阅读样张时,才导出其独立副本。