Skip to content

Theming 主题

ranui 提供基于设计令牌(CSS 自定义属性)的明暗主题系统。组件从不写死颜色,而是消费语义 令牌,因此切换主题或覆盖某个令牌即可一次性重塑整个组件库的样式。令牌体系基于 Geist 设计语言。

Geist 是什么: Geist 是 Vercel 的开源设计体系——vercel.com 与 Next.js 文档站背后用的就是 它。它的核心思想是颜色是一条「状态阶梯」,而非调色板:每条色阶从 100 走到 1000,每一档都有 固定职责(100 默认背景 · 200 悬停 · 300 激活 · 400 边框 · 500 悬停边框 · 600 激活边框 · 700 实心 填充 · 800 实心悬停 · 900 次要文字 · 1000 主要文字)。ranui 把这套阶梯落成 --ran-* 色阶,在其上 叠加语义令牌--ran-color-bg--ran-color-text--ran-color-border 等),并以 Geist Sans / Geist Mono 作为默认字体。实际收益是:暗色模式只需重定义基础色阶——每个语义令牌 都引用它并自动翻转,因此组件天然暗色安全。完整阶梯见下方 令牌分层字体

适用场景:需要为 ranui 应用接入明暗主题时——调用 initTheme / setTheme,并消费语义化的 --ran-color-* 设计令牌,这样切换主题或覆盖某个令牌就能一次性重塑整个组件库的样式。

主题只有 light(浅色)dark(深色) 两种,外加跟随操作系统偏好的 system 模式。 (旧的「主题包(theme pack)」API 已移除,setThemePack / RanThemePackName 不再存在。)

快速开始

页面加载时调用一次 initTheme() 恢复用户上次的选择,再用 setTheme() 切换:

js
import { initTheme, setTheme, getTheme } from 'ranui/theme';

// 从 localStorage 恢复持久化的主题('light' | 'dark' | 'system')
initTheme();

// 切换主题——自动持久化
setTheme('dark');
setTheme('system'); // 跟随 prefers-color-scheme 实时更新

getTheme(); // → 'light' | 'dark' | 'system' | ''

独立的 ranui/theme 入口只包含主题引擎——引入它不会注册任何自定义元素,因此只需要 Token 和暗色模式的页面不会把整个组件库带进来。如果偏好单一引入,这些函数同样从顶层 ranui 主入口重新导出。

setTheme 会在 <html> 上写入 data-ran-theme(以及兼容用的 theme)属性,所有组件样式随之 响应。选择会保存在 localStorage 键 ran-theme 下。

如果需要现成的主题切换 UI,直接使用 <r-theme-switch> 组件—— 一个接入该 API 的 system / light / dark 分段控件。

API

函数签名说明
initTheme(target?: ThemeTarget) => voidlocalStorage 恢复主题,加载时调用一次。SSR 下为空操作。
setTheme(name: RanThemeName, target?: ThemeTarget) => void应用 'light' | 'dark' | 'system' 并持久化;'system' 会实时跟随系统。
getTheme(target?: ThemeTarget) => RanThemeName | ''读取当前主题。system 模式下返回 'system',未设置时返回 ''
setThemeToken(name: string, value: string | number, target?: HTMLElement) => void运行时覆盖单个令牌(作为目标元素的内联样式)。
setThemeTokens(tokens: ThemeTokenMap, target?: HTMLElement) => void批量覆盖令牌,值为 null / undefined 时清除对应令牌。
clearThemeToken(name: string, target?: HTMLElement) => void移除运行时的令牌覆盖。

类型

ts
type RanThemeName = 'light' | 'dark' | 'system';
type ThemeTarget = HTMLElement | Document; // 默认 document.documentElement
type ThemeTokenMap = Record<string, string | number | null | undefined>;

target — 所有函数默认作用于 <html>document.documentElement)。传入某个元素可将 主题或令牌覆盖限定到局部子树。

SSR 安全 — 所有对 document / localStorage / matchMedia 的访问都有守卫,服务端渲染 时这些函数不会抛错,只是空操作。

令牌分层

令牌分为两层,应用中只消费语义层——它会在明暗之间自动翻转。

第一层 —— 基础调色板(原始色阶,很少直接使用):每种颜色从 100 → 1000 共 10 档 —— --ran-gray-100..1000--ran-gray-alpha-100..1000--ran-blue/red/amber/green-100..1000, 以及 --ran-background-100/200

第二层 —— 语义令牌--ran-color-* 等)映射到基础色阶。深色模式只重定义基础色阶,因此每个 语义令牌通过 var() 自动翻转,无需为组件逐一编写深色覆盖。

语义颜色令牌

令牌用途
--ran-color-primary主操作(单色)
--ran-color-primary-hover主操作悬停
--ran-color-primary-active主操作激活
--ran-color-primary-text主操作表面上的反色墨水
--ran-color-success成功
--ran-color-warning警告
--ran-color-danger危险 / 错误
--ran-color-bg页面背景
--ran-color-bg-subtle次级背景
--ran-color-bg-elevated卡片 / 表面背景
--ran-color-bg-muted弱化表面
--ran-color-bg-hover悬停表面
--ran-color-bg-active激活表面
--ran-color-text主文本
--ran-color-text-secondary次级文本
--ran-color-text-disabled禁用文本
--ran-color-border默认边框
--ran-color-border-secondary次级边框
--ran-color-border-hover悬停边框
--ran-color-border-active激活边框
--ran-color-link链接色

primary(主操作) 这组令牌即单色的「最高对比度」操作(Geist 品牌色调,即 <r-button type="primary">):浅色模式黑底白字,深色模式白底黑字。它不携带任何色相—— 蓝色只保留给链接(--ran-color-link)和聚焦环。--ran-color-primary-text 是位于主操作 表面上的文本/图标所用的反色墨水(它也会翻转,因此主按钮在明暗两种模式下都能正确显示)。

颜色是状态阶梯,而非调色板。 在一条色阶内每一档都有固定职责:100 默认背景 · 200 悬停背景 · 300 激活背景 · 400 边框 · 500 悬停边框 · 600 激活边框 · 700 实色 · 800 实色悬停 · 900 次级文本 · 1000 主文本。

非颜色令牌

分组令牌
圆角--ran-radius-sm 6px · --ran-radius-md 12px · --ran-radius-lg 16px · --ran-radius-full
间距--ran-space-1..24(4px 基准:4 · 8 · 12 · 16 · 24 · 32 · 40 · 64 · 96)
阴影--ran-shadow-elevated(在流表面)· --ran-shadow-menu(浮层)· --ran-shadow-modal(对话框)
层级--ran-z-modal 1000 · --ran-z-dropdown 1100 · --ran-z-message 1200
动效--ran-motion-duration-fast 0.15s · --ran-motion-duration-base 0.2s
焦点--ran-focus-ring
排版--ran-font-family(Geist Sans)· --ran-font-mono(Geist Mono)

字体

ranui 自托管了 --ran-font-family / --ran-font-mono 背后的标准字体——Geist SansGeist Mono(可变字重 100–900,SIL OFL 1.1 许可)。字体文件随包分发,一行导入即可加载, 不依赖任何 CDN:

js
// 打包器
import 'ranui/fonts';
html
<!-- 静态页面 -->
<link rel="stylesheet" href="…/ranui/dist/fonts/fonts.css" />

不导入也一切正常——排版令牌会回退到系统字体,只是没有 Geist 字形。

自定义令牌

运行时(JS)

js
import { setThemeToken, setThemeTokens, clearThemeToken } from 'ranui/theme';

// 在 <html> 上覆盖单个令牌(影响全局)
setThemeToken('--ran-color-primary', '#7c3aed');

// 批量覆盖
setThemeTokens({
  '--ran-color-primary': '#7c3aed',
  '--ran-radius-md': '8px',
});

// 限定到子树
setThemeToken('--ran-color-primary', '#e11d48', document.querySelector('#panel'));

// 移除覆盖
clearThemeToken('--ran-color-primary');

构建时(CSS)

:root(或任意作用域)下覆盖语义令牌。由于深色模式只重定义基础色阶,若想做「不随主题翻转」的 改动就覆盖语义令牌,若想让改动也随主题翻转则覆盖基础色阶

css
:root {
  --ran-color-primary: #7c3aed;
  --ran-radius-md: 8px;
}

深色模式原理

setTheme('dark') 会在 <html> 上设置 data-ran-theme="dark"。样式表只为深色重定义第一层基础 色阶(集中在 theme/dark.less 单一来源);每个 --ran-color-* 语义令牌都通过 var() 引用色阶, 因此自动翻转。这也是组件级令牌必须使用深色安全回退的原因——回退值应指向一个会翻转的令牌 (var(--ran-color-text, …)),而不是像 rgba(0,0,0,.06) 这样只适用于浅色的字面量。

Released under the MIT License.