Design system 设计系统

ranui 构建于其上的设计语言,以及表达这套语言的完整令牌清单:组件库声明的每一个全局 --ran-* 自定义属性,都附上它在明暗两套主题下的取值。组件读的是这些令牌而不是写死的数值,所以覆盖一个令牌就能改变所有用到它的地方。

四个页面回答四个不同的问题,刻意拆开:

页面 回答
设计系统(本页) 令牌是什么,即词汇表
设计规范 做界面时如何取舍
信息架构 页面本身该是什么形状
主题系统 运行时如何切换与覆盖

适用场景:需要查某个令牌的名字或取值(颜色角色、间距档位、图标尺寸、投影层级、缓动曲线),或想理解这些阶梯为什么长这样。

设计语言:Geist

ranui 的令牌基于 Geist,也就是 Vercel 的开源设计体系。每条色阶都是一条由固定职责组成的阶梯,每一档对应一个职责,而不是供人随意挑选的深浅色块:200 不是「稍深一点的灰」,它就是「悬停背景」。每一档的职责一旦固定,给某个交互状态选颜色就是查表,不用临场判断。

ranui 把这套阶梯落成 --ran-* 色阶,在其上叠加语义令牌,并以 Geist Sans / Geist Mono 作为默认字体。

两层令牌

第一层,基础色板:下面这些原始色阶,很少直接消费。

第二层,语义令牌--ran-color-* 等,映射到第一层。请使用这一层。 暗色模式只重定义第一层,每个语义令牌都通过 var() 自动翻转,整个组件库不存在任何组件级的暗色覆盖。

--ran-gray-1000        →  #171717(浅色) / #ededed(暗色)     ← 第一层,会翻转
--ran-color-text       →  var(--ran-gray-1000)                   ← 第二层,跟随
--ran-btn-color        →  var(--ran-color-text, …)               ← 组件令牌

这条链条就是整个架构:改基础档位则全局传导,改语义令牌则改一个角色,改组件令牌则只改一个元素。

颜色

状态阶梯

每条色阶从 100 走到 1000,每一档职责固定:

档位 职责 档位 职责
100 默认背景 600 激活边框
200 悬停背景 700 实心填充(按钮/徽标)
300 激活(按下)背景 800 实心填充(悬停)
400 默认边框 900 次要文字与图标
500 悬停边框 1000 主要文字与图标

背景

令牌 浅色 暗色 用于
--ran-background-100 #ffffff #000000 页面背景
--ran-background-200 #fafafa #000000 轻微区分区域

灰阶 —— --ran-gray-100..1000

文字、边框与表面背后的色阶。

档位 浅色 暗色
100 #f2f2f2 #1a1a1a
200 #ebebeb #1f1f1f
300 #e6e6e6 #292929
400 #eaeaea #2e2e2e
500 #c9c9c9 #454545
600 #a8a8a8 #878787
700 #8f8f8f #8f8f8f
800 #7d7d7d #7d7d7d
900 #4d4d4d #a0a0a0
1000 #171717 #ededed

半透明灰 —— --ran-gray-alpha-100..1000

半透明,可以叠在任意表面上。遮罩、悬停蒙层,或者必须压在未知内容之上的分隔线,都该用它。

档位 浅色 暗色
100 #0000000d #ffffff12
200 #00000015 #ffffff17
300 #0000001a #ffffff21
400 #00000014 #ffffff24
500 #00000036 #ffffff3d
600 #0000003d #ffffff82
700 #00000070 #ffffff8a
800 #00000082 #ffffff78
900 #000000b3 #ffffff9c
1000 #000000e8 #ffffffeb

蓝 —— --ran-blue-100..1000

只保留给链接与聚焦环。

档位 浅色 暗色
100 #f0f7ff #06193a
200 #e9f4ff #022248
300 #dfefff #002f62
400 #cae7ff #003674
500 #94ccff #00418b
600 #48aeff #0090ff
700 #006bff #006efe
800 #0059ec #005be7
900 #005ff2 #47a8ff
1000 #002359 #eaf6ff

红 —— --ran-red-100..1000

危险与错误。

档位 浅色 暗色
100 #ffeeef #330a11
200 #ffe8ea #440d13
300 #ffe3e4 #5d0e17
400 #ffd7d6 #6f101b
500 #ffb1b3 #88151f
600 #ff676d #f32e40
700 #fc0035 #f13242
800 #ea001d #e2162a
900 #d8001b #ff565f
1000 #47000c #ffe9ed

琥珀 —— --ran-amber-100..1000

警告。

档位 浅色 暗色
100 #fff6de #2a1700
200 #fff4cf #361900
300 #fff1c1 #502800
400 #ffdc73 #5b3000
500 #ffc543 #703e00
600 #ffa600 #ed9a00
700 #ffae00 #ffae00
800 #ff9300 #ff9300
900 #aa4d00 #ff9300
1000 #561900 #fff3d5

绿 —— --ran-green-100..1000

成功。

档位 浅色 暗色
100 #ecfdec #002608
200 #e5fce7 #00320b
300 #d3fad1 #003a0e
400 #b9f5bc #004615
500 #82eb8d #006717
600 #4ce15e #00952d
700 #28a948 #00ac3a
800 #279141 #009432
900 #107d32 #00ca50
1000 #003a00 #d8ffe4

语义颜色令牌

组件真正读取的那一层。这里的一切都通过上面的色阶解析,因此会自己跟着主题翻转。

令牌 解析到 职责
--ran-color-bg --ran-background-100 页面背景
--ran-color-bg-subtle --ran-background-200 轻微区分的区域
--ran-color-bg-elevated --ran-background-100 · 暗色下为 gray-100 卡片、表面
--ran-color-bg-muted --ran-gray-100 内凹 / 弱化填充
--ran-color-bg-hover --ran-gray-200 悬停表面
--ran-color-bg-active --ran-gray-300 激活(按下)表面
--ran-color-text --ran-gray-1000 主要文字
--ran-color-text-secondary --ran-gray-900 次要文字
--ran-color-text-disabled --ran-gray-700 禁用文字
--ran-color-border --ran-gray-400 默认边框
--ran-color-border-secondary --ran-gray-300 更弱的边框
--ran-color-border-hover --ran-gray-500 悬停边框
--ran-color-border-active --ran-gray-600 激活边框
--ran-color-primary --ran-gray-1000 主操作(无彩色)
--ran-color-primary-hover #383838 · 暗色 #cccccc 主操作悬停
--ran-color-primary-active #4d4d4d · 暗色 #b3b3b3 主操作按下
--ran-color-primary-text --ran-background-100 主操作表面之上的文字
--ran-color-success --ran-green-700 成功
--ran-color-warning --ran-amber-700 警告
--ran-color-danger --ran-red-700 危险 / 错误
--ran-color-link --ran-blue-700 链接

--ran-color-primary-hover / -active 是语义层里仅有的两个字面量:它们是朝页面背景方向走的,而不是沿着某条色阶走,所以暗色模式直接重定义了它们。

每个色彩语义只有一个含义

  • 主操作是无彩色的:浅色下黑底白字,暗色下白底黑字(Geist 的品牌调性, <r-button type="primary">)。其上的文字与图标用 --ran-color-primary-text,会一起翻转。这里没有单独的「contrast」令牌:主操作本身就是对比度最高的那一个。
  • 蓝色是保留色,只用于链接(--ran-color-link)与聚焦环,不是备选的主色。
  • 绿色=成功 · 琥珀=警告 · 红色=危险,一色一义。

不存在 --ran-color-error,危险色叫 --ran-color-dangervar() 引用一个从未声明过的属性会解析为「空」,整条声明会被丢弃,而且不会有任何报错,所以名字宁可对着表查,也别猜。

间距

元素之间的距离:paddingmargingap。以 4px 为基数,只有九档

令牌 令牌
--ran-space-1 4px --ran-space-8 32px
--ran-space-2 8px --ran-space-10 40px
--ran-space-3 12px --ran-space-16 64px
--ran-space-4 16px --ran-space-24 96px
--ran-space-6 24px

数字是 4px 的倍数,所以档位是跳着的,没有 --ran-space-5。这正是重点:档位有限,页面才有节奏。

尺寸

元素自身的尺寸:图标大小、控件高度、小的方形/矩形控件。

令牌 典型用途
--ran-size-1 16px 多选框方块、小号内联图标
--ran-size-2 18px
--ran-size-3 20px 控件内部的图标
--ran-size-4 24px 工具栏图标按钮
--ran-size-5 28px 紧凑控件高度
--ran-size-6 30px
--ran-size-7 32px 默认控件高度

这是刻意与间距分开的另一条尺度,混用会被机器校验拦下(sizing-scale 规则)。两者的取值范围和递进方式不同:4px 翻倍式的间距尺度用在图标和控件尺寸上会得出别扭的数值;而且使用者必须能在不动另一个的前提下单独调整其中一个:图标变大,不应该顺带把每一个恰好同值的间隙也撑开。某一档在数值上与间距档位重合(--ran-size-4--ran-space-6 都是 24px)只是巧合,不是别名。

真正一次性、没有别的组件共享的尺寸(比如某个菜单的 min-width),就保持为带自己字面量兜底的组件令牌,不要硬塞进某一档。

排版

令牌
--ran-font-family Geist / Geist Sans,其后是系统 UI 字体栈
--ran-font-mono Geist Mono,其后是 ui-monospace、SF Mono、Menlo、Consolas…
--ran-font-size 14px,基准字号
--ran-line-height 1.5715

排版按角色组织,角色一旦确定,字体、字号、字重、行高就一起定了:

角色 用于 字重令牌 字号令牌
heading 标题 --ran-text-heading-weight(600) --ran-text-heading-1..4(32/24/20/16px)
label 单行、可扫读 --ran-text-label-weight(500) --ran-text-label-1..3(14/13/12px)
copy 多行正文 --ran-text-copy-weight(400) --ran-text-copy-1..2(16/14px)
button 按钮文字 --ran-text-button-weight(500) --ran-text-button-size(14px)
mono 代码、数据 --ran-text-mono-weight-regular(400)/ --ran-text-mono-weight-medium(500) 复用 label / copy 档位

另有两个令牌只是为了让角色落地正确:

令牌 原因
--ran-text-heading-tracking -0.03em 大字号标题需要更紧的字距。
--ran-text-button-line-height 1 定高控件内的文字才能锐利地居中。

Geist 的字重最高只到 600(semibold),强调靠字号和留白,而不是更粗的字重。没有 --ran-text-copy-3:12px 那一档叫 --ran-text-label-3

字体

ranui 自托管这两套字体(可变字重 100–900,SIL OFL 1.1 许可),一次引入即可,不依赖 CDN:

import 'ranui/fonts'; // 打包器
<link rel="stylesheet" href="…/ranui/dist/fonts/fonts.css" />

不引入也能正常工作,只是回退到系统字体栈。

圆角

令牌 用于
--ran-radius-sm 6px 控件(按钮、输入框、选择器)
--ran-radius-md 12px 卡片、对话框
--ran-radius-lg 16px 大面积表面
--ran-radius-full 9999px 胶囊、头像

投影

投影是角色,不是装饰,按元素「是什么」来选层级。暗色模式会把三档全部替换,因为为白色页面调过的投影放到黑色页面上就看不见了。

令牌 用于 浅色 暗色
--ran-shadow-elevated 文档流内、同时带边框的表面,如 r-cardr-section 0 1px 2px rgba(0,0,0,.04), 0 2px 4px -2px rgba(0,0,0,.05) 0 1px 2px rgba(0,0,0,.16)
--ran-shadow-menu 浮在内容之上的临时层,如下拉、选择面板、气泡卡片、toast 0 2px 4px rgba(0,0,0,.05), 0 8px 24px -6px rgba(0,0,0,.14) 0 1px 1px rgba(0,0,0,.2), 0 4px 8px -4px rgba(0,0,0,.4), 0 16px 24px -8px rgba(0,0,0,.5)
--ran-shadow-modal 阻塞式对话框,如 r-modal 0 4px 12px rgba(0,0,0,.08), 0 20px 48px -12px rgba(0,0,0,.22) 0 1px 1px rgba(0,0,0,.2), 0 8px 16px -4px rgba(0,0,0,.4), 0 24px 32px -8px rgba(0,0,0,.5)

无边框的浮层只靠投影与页面拉开距离,所以浮层层级的投影必须有真实重量;浮层若回退到「抬起」层级,看起来就像贴在页面上。

层级

浮层会 portal 到 <body>,因此需要明确的层级:

令牌 默认值 用于
--ran-z-modal 1000 阻塞式对话框及其遮罩
--ran-z-dropdown 1100 下拉 / 选择面板 / 气泡卡片,高于 modal,弹窗内的选择面板才不会被盖住
--ran-z-message 1200 toast 与通知,永远在最上层

阶梯从 1000 起,是为了越过常规页面骨架(导航栏、遮罩通常在几十的量级)。可以在 :root 上整体覆盖,也可以按组件覆盖(--ran-dropdown-host-z-index--ran-modal-root-z-index--ran-message-z-index),但不要用 !important

动效

令牌 用于
--ran-motion-duration-fast 0.15s 悬停 / 激活状态过渡
--ran-motion-duration-base 0.2s 气泡、菜单
--ran-motion-duration-slow 0.35s 较大的展开
缓动令牌 曲线 性格
--ran-motion-ease-standard cubic-bezier(0.645,0.045,0.355,1) in-out,通用
--ran-motion-ease-snappy cubic-bezier(0.33,0,0.15,1) 干脆、无回弹,用于开关等小状态
--ran-motion-ease-spring cubic-bezier(0.34,1.26,0.5,1) 轻微回弹,用于按钮、卡片
--ran-motion-ease-bouncy cubic-bezier(0.34,1.56,0.64,1) 明显回弹,用于点赞、加入购物车
--ran-motion-ease-smooth cubic-bezier(0.4,0,0.2,1) 平缓、无回弹,用于展开与布局

spring 这一族是把调好的 SwiftUI 弹簧参数(response / damping)折算成的单次回弹贝塞尔曲线。

只把它们用在运动属性上,即 transformopacity 和盒模型几何属性。调色属性(background-colorcolorborder-colorbox-shadowfillstroke)刻意不带默认过渡: CSS 分不清「交互」和「主题翻转」,你给颜色加的淡入淡出,在明暗切换时同样会触发。每个组件仍然保留 --ran-*-transition 钩子,需要时可以自行开启。

聚焦

令牌 用于
--ran-focus-ring 0 0 0 2px var(--ran-background-100), 0 0 0 4px var(--ran-blue-700) 标准聚焦环,形式是 box-shadow
--ran-focus-ring-inverse-color #fff 两套主题下都是深色的表面上的环颜色

聚焦环是双层的(内层用背景色,外层用蓝色),所以在任何表面上都清晰可见;而且它保持蓝色,不跟随已经变成无彩色的主操作色。

--ran-focus-ring-inverse-color 刻意没有在暗色模式里重定义:它是给那种「无论页面主题如何、自身表面始终是深色」的组件用的(r-player 覆盖在任意视频之上的控制条),而那种表面并不随页面主题变化。

皮肤基元

组件共享的、既不属于颜色也不属于尺寸和排版的少数结构性取值。刻意保持精简:这一层以前大得多,大部分已经随主题包一起移除了。

令牌 用于
--ran-skin-border-width 1px 组件绘制的边框宽度
--ran-skin-border-style solid 组件绘制的边框样式
--ran-skin-border-image-width 4px border-image-slice 的内缩,button/checkbox/input/modal/message 共享
--ran-skin-raised-shadow var(--ran-shadow-elevated) 抬起表面的投影,做一层间接以便皮肤替换
--ran-skin-font-family var(--ran-font-family) 组件使用的字体族,同样做了一层间接

暗色模式重定义了哪些

<html> 上的 data-ran-theme="dark"(也可以只作用于某棵子树,见主题系统只重定义基础色板,外加三处无法通过色阶解析的例外:

  • 第一层的全部:gray、gray-alpha、blue、red、amber、green 的每一档,以及两个背景;
  • --ran-color-bg-elevated,暗色下指向 --ran-gray-100,这样卡片才能从黑色页面上浮起来而不是融进去;
  • --ran-color-primary-hover / -active,它们是字面量而不是色阶引用;
  • 三档投影,为深色底重新调过。

其余的一切(所有其他语义令牌、所有尺寸、所有时长)都只定义一次。

组件令牌

语义层之下,每个组件还暴露自己的钩子,命名为:

--ran-{component}-{element}[-{state}]-{property}

例如 --ran-btn-hover-background--ran-select-search-active-border-width。它们默认指向语义令牌,例如 var(--ran-btn-background, var(--ran-color-primary, #171717)),所以覆盖语义令牌能一次影响全部,覆盖组件令牌则只改一个元素。

完整清单见仓库中的 style-tokens-public.md,逐元素接口见元素 API。怎么覆盖见主题系统

在自己的 CSS 里使用令牌

.panel {
  background: var(--ran-color-bg-elevated);
  color: var(--ran-color-text);
  border: var(--ran-skin-border-width) var(--ran-skin-border-style) var(--ran-color-border);
  border-radius: var(--ran-radius-md);
  padding: var(--ran-space-4);
  box-shadow: var(--ran-shadow-elevated);
}

三条规则保证它暗色安全:

  1. 该跟随主题的值,不要写死 hex
  2. 兜底值必须是会翻转的令牌,写 var(--ran-color-text, var(--ran-gray-1000)),不要写 var(--ran-color-text, #171717)
  3. 兜底值引用的令牌必须存在,否则整条声明被丢弃,元素静默沿用继承来的样式。

组件库声明的每一个全局令牌都在本页列出;新增令牌若没有在这里记录,单元测试会失败。组件级令牌另行生成,见 style-tokens-public.md