zz design system
mono & squared
扩展 / 主题

主题

主题不是编译期开关,而是一组普通自定义属性:--zz-* 定义在 :root 与[data-theme='…'] 上,切换主题只是换一个 data-theme 值。 因为它们是继承的自定义属性,任何子树都能局部换主题——一块卡片、一个侧栏,甚至下面这两个并排的面板。

机制

三件事同时成立,才能既有全局主题又有局部主题:

环节做法为什么
定义token 写在 :root 与 [data-theme='dark' | 'light'] 块里普通自定义属性,继承并可在任意层级被覆盖
消费组件只写 var(--zz-*),不写 --color-zz-*,不写死色值--color-zz-* 是 @theme inline 桥接出的 Tailwind 命名空间,值被内联进工具类,不保证作为运行时变量存在
作用域任何元素加 data-theme,其子树立即用新主题渲染局部主题与全局主题走同一条路径,没有第二套机制
<!-- 深色页面里嵌一块亮色区域:不加类、不加脚本 -->
<article data-theme="light">
  <h3>这张卡片是亮色的</h3>
  <p>里面的按钮、输入框、描边全部自动跟随。</p>
</article>

别忘了 color-scheme:主题块里跟着写 color-scheme: dark / light, 原生控件(滚动条、输入框、日期选择器)才会跟着局部主题走。

并排对比

两块内容完全相同,只有 data-theme 不同。它们的样式只依赖 token, 所以局部主题在这里是真实生效的,不是截图替换。

data-theme='dark'

局部主题作用域

这段正文的颜色来自 --zz-text-secondary;改容器的 data-theme,它整体换色。

stable

卡片小样

卡片底是 --zz-surface,浮起用 --zz-elevation。

2026-10-15 · 1,204 字

data-theme='light'

局部主题作用域

这段正文的颜色来自 --zz-text-secondary;改容器的 data-theme,它整体换色。

stable

卡片小样

卡片底是 --zz-surface,浮起用 --zz-elevation。

2026-10-15 · 1,204 字

<!-- 同一批组件、同一份标记,只有容器的 data-theme 不同 -->
<div data-theme="dark">
  <Button>主操作</Button>
  <Button variant="outline">次要描边</Button>
  <Badge variant="brand">stable</Badge>
  <Input placeholder="输入框 · 控件描边" />
  <Card title="卡片小样" description="…" meta="2026-10-15 · 1,204 字" />
</div>

<div data-theme="light">
  <!-- 完全相同的标记 -->
</div>

注意两边的描边与"浮起"并不相同:深色面板靠一圈 1px 外描边区分层级,亮色面板叠了极轻的投影。 这是 token 值的差异,不是组件的差异——两块面板里放的是同一批组件(Button / Badge / Input / Card), 没有任何一行按主题分支的样式。

切换主题

全局切换就是改根元素的属性并记住选择。用它自己的过渡,别让每个颜色各自补间。

// 切换 + 记住选择
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
localStorage.setItem('zz-theme', next)

// 想让切换走那道斜切裂缝(并在切换瞬间关掉全局过渡)
document.documentElement.classList.add('zz-no-transitions')
const transition = document.startViewTransition(() => {
  document.documentElement.dataset.theme = next
})
transition.finished.finally(() => document.documentElement.classList.remove('zz-no-transitions'))

首屏不能等 JS 跑完再决定主题,否则会闪一下默认主题。把决定提前到 <head> 里的内联脚本, 在任何样式内容绘制之前写 data-theme:

<!-- <head> 内,必须是 is:inline,且不依赖任何外部脚本 -->
<script is:inline>
  ;(() => {
    const stored = localStorage.getItem('zz-theme')
    const system = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
    document.documentElement.dataset.theme =
      stored === 'light' || stored === 'dark' ? stored : system
  })()
</script>
  • 脚本必须在 <head>,且是内联的:外链脚本会晚一步执行,闪白就回来了。
  • 取值顺序是「已存的选择 → 系统偏好 → 兜底 dark」。非法值(被改过的 localStorage)一律回落到系统偏好。
  • prefers-reduced-motion: reduce 时跳过 startViewTransition,直接同步切换。

新增一套主题

复制 [data-theme='light'] 整块,改选择器名,然后按下表逐项决定。总共有12 个颜色值 + field 六阶 + 3 个覆盖 token,再加两行层级与一行 color-scheme。

分组token决定什么
面 4--zz-bg-deep --zz-bg --zz-surface --zz-surface-2凹槽 → 页面 → 面板 → 悬停面,必须逐级可区分
线 2--zz-border-subtle --zz-border-strong发丝线(常与 surface-2 同值)与控件描边;后者必须明显看得见
文字 3--zz-text --zz-text-secondary --zz-text-muted三档实色,各自独立对背景校对比度,不用透明度派生
品牌 3--zz-brand --zz-brand-soft --zz-brand-ink强调色、它的 12% 透明版、填充之上的文字色
field 六阶--zz-field-bg --zz-field-dim --zz-field-mid --zz-field-lit --zz-field-hover --zz-field-crest品牌明度梯:字标渐变、像素场、滚动条滑块的 hover / active
覆盖 3--zz-img-outline --zz-scroll-thumb --zz-selection图片 1px 描边、滚动条滑块、文本选区底色
层级 2--zz-elevation --zz-elevation-hover亮色主题叠真实投影,深色主题只用零模糊描边
[data-theme='ocean'] {
  color-scheme: dark;               /* 决定原生控件与滚动条的明暗 */

  /* 面 4 */
  --zz-bg-deep: …;
  --zz-bg: …;
  --zz-surface: …;
  --zz-surface-2: …;

  /* 线 2 */
  --zz-border-subtle: …;            /* 发丝线,通常与 surface-2 同值 */
  --zz-border-strong: …;            /* 控件描边 */

  /* 文字 3 */
  --zz-text: …;
  --zz-text-secondary: …;
  --zz-text-muted: …;

  /* 品牌 3:soft 保持派生,不用手改 */
  --zz-brand: …;
  --zz-brand-soft: color-mix(in srgb, var(--zz-brand) 12%, transparent);
  --zz-brand-ink: …;

  /* field 六阶 */
  --zz-field-bg: …;
  --zz-field-dim: …;
  --zz-field-mid: …;
  --zz-field-lit: …;
  --zz-field-hover: …;
  --zz-field-crest: …;

  /* 覆盖 3 */
  --zz-img-outline: …;
  --zz-scroll-thumb: …;
  --zz-selection: …;

  /* 层级 2:深色用 1px 零模糊描边,亮色才叠投影 */
  --zz-elevation: 0 0 0 1px oklch(1 0 0 / 0.07);
  --zz-elevation-hover: 0 0 0 1px oklch(1 0 0 / 0.13);
}
  • --zz-brand-soft 保持 color-mix 派生行不动,实际只需手写 11 个颜色值。
  • 新主题挂上去即可用:document.documentElement.dataset.theme = 'ocean',或给任意子树加 data-theme="ocean"。
  • 交付前跑 node scripts/check-tokens.mjs,它只允许色值出现在 packages/design-system/src/styles/tokens.css。
  • 验收清单:四个面逐级可区分、两种线的强弱关系正确、焦点环在 --zz-bg 与 --zz-surface 上都看得见、正文在两种主题下都能读。