---
title: "Web"
description: "在任何 Web 项目中使用 Tanrei 的 CSS 令牌和 Web Components。"
url: "https://tanrei.chnotchy.com/zh-cn/getting-started/web/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Web

> 在任何 Web 项目中使用 Tanrei 的 CSS 令牌和 Web Components。

`@tanrei/web` 包含两样东西：一份以 CSS 自定义属性提供设计令牌的样式表，以及用 Lit 构建的、基于标准的自定义元素。它不要求使用任何框架。

> [!WARNING]
> **预发布**
>
> `0.1.0-next.0` 已以 dist-tag `next` 发布到 npm：在 `0.1.0` 之前，请安装 `@tanrei/web@next`。在 0.x 预发布期间不保持向后兼容：任何一个预发布都可能破坏任何东西，事先没有弃用期，破坏了什么写在它的 changeset 里。

## 安装

### 从 npm

```sh
npm install @tanrei/web
```

### 从源码

要在发布之前使用 `main` 上的源码，你需要 Git、Node 22.22 或更高版本，以及 pnpm 12（`corepack enable` 会给你仓库固定的那个版本）。在仓库的克隆中构建这个包并打包，再把打包出的文件安装到你的项目里：

```sh
git clone https://github.com/chnotchy/tanrei.git
cd tanrei
pnpm install
pnpm --filter @tanrei/web build
pnpm --filter @tanrei/web pack --pack-destination ../tanrei-packages
```

```sh
# 在你的项目中：路径指向写入这些包的文件夹
npm install path/to/tanrei-packages/tanrei-web-*.tgz
```

打包出的文件包含的正是发布时发布的内容，所以下面的一切用法都完全相同。要取用更新的源码，就拉取、重新构建并打包，然后安装新的文件。

## 使用

1. 尽早加载一次样式表。它们都被包在级联层（`tanrei.*`）中，因此你自己的样式始终优先。

   ```js
   import '@tanrei/web/tanrei.css';
   ```

   `tanrei.css` 把一切放进一个文件：页面的基础样式、令牌，以及不用元素绘制的部件（文本样式、表格、手风琴等）的样式表。只想加载用得到的部分，就加载令牌、需要的话再加上基础样式，然后按需加载那些样式表：

   ```js
   import '@tanrei/web/tokens.css';
   import '@tanrei/web/base.css'; // 让页面使用系统的表面色、文字颜色和正文字体，并去掉 body 的外边距
   ```

   基础样式让页面本身使用表面色，让你自己的文字使用 on-surface 颜色和正文字体，并去掉浏览器给 body 加的外边距。没有它，页面保持浏览器的默认样式。

2. 注册你的设计系统的组件，也可以只导入用到的元素。

   ```js
   import '@tanrei/web';
   // 使用预设时，改为导入它的入口：m3-like、m3-expressive-like 或 cupertino
   import '@tanrei/web/m3-like';
   // 或者，一次导入一个元素：
   import '@tanrei/web/components/button';
   ```

   `@tanrei/web` 只注册默认系统包含的组件，所以基于它的产品接触不到系统没有的组件。预设的入口 `@tanrei/web/<id>` 注册该预设包含的组件，`@tanrei/web/all` 注册包里的全部组件。只有预设才包含的组件，例如[分段按钮](https://tanrei.chnotchy.com/zh-cn/components/segmented-button/)，仍然可以有意地使用：在你的系统的层里引入它，并导入它自己的模块。

   `@tanrei/web/…` 这类说明符是包的导出，由打包工具（Vite、webpack、esbuild）解析。没有打包工具时，浏览器无法解析 `<link>` 或 `<script src>` 中的裸说明符，这时请直接提供包的 `dist/` 目录并指向其中的文件：样式表原样使用，脚本使用 `tanrei.bundle.js`，它包含 Lit 和所有元素：

   ```html
   <link rel="stylesheet" href="/node_modules/@tanrei/web/dist/tanrei.css" />
   <script type="module" src="/node_modules/@tanrei/web/dist/tanrei.bundle.js"></script>
   ```

3. 像使用其他 HTML 一样使用这些元素。

   ```html
   <tanrei-button variant="filled">部署</tanrei-button>
   <tanrei-button variant="outlined" size="small" disabled>取消</tanrei-button>
   ```

4. 给出你的产品的文字。组件没有自己的文字：必填和选填小标签上的字、关闭按钮的名称等等，都是你的产品的。想先让每一个都用平实的英语，就导入一次英语文字包：

   ```js
   import '@tanrei/web/words/en';
   ```

   要改其中任何一个，或者给出另一种语言的文字，就在它之后调用 `setWords()`。你给出的文字优先于包中的文字，写在元素上的文字又优先于这两者。

   ```js
   import { setWords } from '@tanrei/web';

   setWords({ requiredText: 'Mandatory' });
   setWords({ requiredText: '必填', optionalText: '选填', closeLabel: '关闭' }, { lang: 'zh-cn' });
   ```

   既没有这个包、也没有你的文字时，组件仍然可以工作，并在控制台里告诉你缺的是哪一个。参见[文字](https://tanrei.chnotchy.com/zh-cn/foundations/words/)。

## 元素清单

编辑器和编程代理从包的 [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest)（`@tanrei/web/custom-elements.json`）读取元素：每个元素的属性、特性、事件、插槽和部件，不包含产品无法访问的内容。`@tanrei/web/custom-elements/<id>.json` 只包含系统 `<id>` 提供的元素，所以在一个系统里工作的工具不会推荐别的元素。

## 主题与对比度

令牌的值使用了 `light-dark()`，因此会跟随所在元素的 `color-scheme`。开箱即用时，这意味着跟随操作系统的设置，并且在 `prefers-contrast: more` 时切换到高对比度方案。

如果想自己控制，可以设置属性：

```html
<!-- 为整个页面或任意子树强制指定主题 -->
<html data-tanrei-theme="dark">

<!-- 为页面强制指定对比度级别："medium" 或 "high" -->
<html data-tanrei-contrast="high">
```

> [!NOTE]
> **注意**
>
> 这两个属性既可以用在 `<html>` 上，也可以用在任意子树上。生成的样式表把整套令牌声明在 `:root, [data-tanrei-contrast]` 上，因此在嵌套元素上设置 `data-tanrei-contrast` 时，其中的组件令牌也会按该对比度级别重新解析，而不只是系统颜色。

### 记住用户选择的主题

如果用户可以在你的产品里选择主题，就要保存这个选择，并在页面第一次绘制之前把它放到 `<html>` 上：如果由之后才运行的脚本来设置，页面会先用系统的主题显示一帧。`@tanrei/web/theme-script` 两件事都做。`themeScript()` 是一段要内联在 `<head>` 里、写在样式表之前的小脚本，`saveTheme()` 保存一个选择并立即绘制它。

```js
import { loadTheme, saveTheme } from '@tanrei/web/theme-script';

saveTheme('dark'); // 'light'、'dark'，或者 'system'，重新跟随操作系统
loadTheme();       // 保存的内容：'light'、'dark' 或 'system'
```

在 Next.js（App Router）中，把它内联在根布局里。加上 `suppressHydrationWarning`，是因为脚本会在 React 水合之前改变 `<html>` 的一个属性。

```tsx
// app/layout.tsx
import { themeScript } from '@tanrei/web/theme-script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript() }} />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

在 Astro 中，把它内联在布局里：

```html
---
// src/layouts/Layout.astro
import { themeScript } from '@tanrei/web/theme-script';
---
<html lang="en">
  <head>
    <script is:inline set:html={themeScript()} />
  </head>
  <body><slot /></body>
</html>
```

## 在自己的 CSS 中使用令牌

每个令牌都是一个以其路径命名的自定义属性：`tanrei.sys.color.primary` 对应 `--tanrei-sys-color-primary`。

```css
.panel {
  background: var(--tanrei-sys-color-surface-container-low);
  border-radius: var(--tanrei-sys-shape-corner-medium);
  box-shadow: var(--tanrei-sys-elevation-level1);
  font: var(--tanrei-sys-typescale-body-font);
}
```

要从脚本设置令牌，请使用 `@tanrei/web` 的 `setTokens()`：在根上是 `setTokens({ '--tanrei-sys-color-primary': '#0b57d0' })`，对页面的一部分则加上 `{ on: element }`。在根上用 `style.setProperty()` 单独设置一个令牌，会让 Safari 花几秒钟重新计算整个页面的样式；`setTokens()` 的写法不会这样。

## 文本样式

文本样式按层级命名（`heading-2` 对应 `h2`，`body` 对应 `p`，`label` 对应控件上的文字），每个样式都是一组自定义属性：简写属性 `--tanrei-sys-typescale-<role>-font`，以及 `-font-family`、`-font-size`、`-font-weight`、`-line-height`、`-letter-spacing` 和 `-font-weight-emphasized`。另有两份可选的样式表，可以替你把它们应用到元素上：

```js
import '@tanrei/web/text.css';   // .tanrei-text-<role>、.tanrei-text-emphasized
import '@tanrei/web/prose.css';  // .tanrei-prose 内的 h1–h6、p、small、code、pre
```

```html
<h2 class="tanrei-text-heading-2">章节</h2>
<p class="tanrei-text-body">带有<strong class="tanrei-text-emphasized">强调</strong>的连续文本。</p>
<article class="tanrei-prose"><!-- 渲染后的 Markdown --></article>
```

`.tanrei-text-emphasized` 会以所在角色的字重加上该系列的强调级差来绘制文本；它是一种字重，而不是另一个角色。十六个角色及其与元素的对应关系见[字体排印](https://tanrei.chnotchy.com/zh-cn/foundations/typography/)。

## 字体

字阶指定的字体是 Inter、Noto Sans JP、Noto Sans 和 Roboto Mono（`tokens.css` 中的 `--tanrei-ref-typeface-{brand,plain,mono}`；`tanrei build` 会为任何系统打印这份列表），图标指定的是 Material Symbols Rounded，并且全部回退到系统字体。Tanrei 不打包字体文件；请自行加载，并使用完全相同的字体族名称。可以从 Google Fonts 加载，也可以使用 `@fontsource-variable/*` 包（它们注册的名称是 `Inter Variable` 之类，因此需要像本站一样，自己声明一条 `@font-face`，用不带后缀的名称指向它们的文件）。

## 图标

```js
import '@tanrei/web/icons.css';
```

```html
<tanrei-button><span slot="icon" class="tanrei-icon" aria-hidden="true">add</span>添加</tanrei-button>
```

`.tanrei-icon` 以系统的图标字重绘制一个 [Material Symbols](https://fonts.google.com/icons) 名称，尺寸取决于它所在的位置。你无需选择线框还是填充：参见[图标](https://tanrei.chnotchy.com/zh-cn/foundations/icons/)。

完整的 Material Symbols Rounded 文件包含所有字形的所有字重、填充、等级和视觉尺寸，共 5.4 MB；在慢速网络上，页面可能要为它等上半分钟。只加载你的产品会绘制的字形。`tanrei build` 会打印 Tanrei 组件自身可能绘制的字形名称（箭头、对勾、关闭按钮的叉号……），以及你的系统绘制图标所用的轴值；把你自己的名称加进这份清单。使用 Google Fonts 时，把名称按字母顺序排好，作为 `icon_names=` 传入，并在 `family=` 中以范围写出各轴（默认系统为 `Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,300..500,0..1,0`）。自行托管文件时，用一个保留字体连字特性（`rlig`、`rclt`）和两种填充的子集工具来裁剪；本站的图标就是用这样的文件绘制的。无论哪种方式，都要以 `font-display: block` 注册图标字体：文件到达之前图标保持不可见，绝不会以字母显示出它的名称。

## 浏览器支持

Tanrei 在以下版本及更高版本中可用：

| 浏览器 | 版本 |
| --- | --- |
| Chrome 和 Edge | 125 |
| Firefox | 126 |
| Safari（macOS 和 iOS） | 17.5 |

每个数字都来自 Tanrei 绘制时离不开的功能中最新的那一项，这些功能都没有替代方案：

- **CSS**：所有颜色都是 `light-dark()` 写成的一对值（Chrome 和 Edge 123、Firefox 120、Safari 17.5）。尺寸和字号用 CSS 的 `round()` 取整（Chrome 和 Edge 125、Firefox 118、Safari 15.4）。组件依据自定义状态 `:state()` 切换样式（Chrome 和 Edge 125、Firefox 126、Safari 17.4）。菜单、工具提示和消息条通过 Popover API 在顶层打开（Chrome 和 Edge 114、Firefox 125、Safari 17）。包裹样式表的级联层更早就已支持（Chrome 和 Edge 99、Firefox 97、Safari 15.4）。
- **JavaScript**：这个包是 ES2022 的，只要它发布的任何一个文件超出 ES2022，构建就会失败。它用到的最新语法是类的静态初始化块，从 Chrome 和 Edge 94、Firefox 93、Safari 16.4 起即可运行。把这个包转译到更旧的目标并不能降低下限：上面的 CSS 不会因此改变。

少数更新的功能只用在缺少它们也能正常工作的地方：没有 `@starting-style` 时，打开时的动画不会播放；没有 `corner-shape` 时，转角就保持普通的圆角。

### 告诉访客浏览器版本太旧

Tanrei 自己不做这件事：这是产品入口的职责。先检测这些功能，只有都具备时才加载应用，连同 Tanrei 一起：

```html
<p id="unsupported" hidden>本网站需要 Chrome 或 Edge 125、Firefox 126、Safari 17.5 或更高版本。</p>
<script type="module">
  // light-dark() 决定 Safari 的下限，round() 决定 Chrome 和 Edge 的下限，:state() 决定 Firefox 的下限
  const supported =
    CSS.supports('color', 'light-dark(red, red)') &&
    CSS.supports('width', 'round(1px, 1px)') &&
    CSS.supports('selector(:state(open))');
  if (supported) import('./app.js'); // 你的应用，由它导入 @tanrei/web
  else document.getElementById('unsupported').hidden = false;
</script>
```

这段脚本要写得小而朴素：每位访客的浏览器都会运行它，旧浏览器也不例外。如果用静态 `import`，浏览器会在这段脚本运行之前就加载应用，无法解析应用的旧浏览器根本走不到检测这一步；`import()` 则在检测之后才加载。

## 测试

### 在浏览器中运行

Tanrei 的元素建立在浏览器所实现的能力之上：shadow root、`ElementInternals`（表单控件的值与有效性、自定义状态）、Popover API、`<dialog>` 以及布局。jsdom 和 happy-dom 只实现了其中一部分（happy-dom 既没有 `attachInternals()`，也没有 Popover API），因此元素可能在渲染时抛出异常，留下被拒绝的 Promise，或者在缺少其所依赖的尺寸、焦点顺序和顶层（top layer）的情况下渲染。Tanrei 不支持这些环境，也不提供替代它们所缺功能的桩代码：一个只让元素不再抛出异常的替代品，会让测试在任何浏览器都不存在的行为上通过。

凡是会渲染 Tanrei 元素的测试，都请在真实浏览器中运行：

- [Vitest 的浏览器模式](https://vitest.dev/guide/browser/)配合其 Playwright provider，可以让组件测试留在 Vitest 中，并在 Chromium、Firefox 或 WebKit 中运行。
- [Playwright Test](https://playwright.dev/docs/intro) 用于运行页面和完整的操作流程。

不渲染任何 Tanrei 元素、只测试你自己逻辑的测试，可以继续留在 Node、jsdom 或 happy-dom 中。Tanrei 自己的测试在 Chromium、Firefox 和 WebKit 中运行，并在 Playwright 中使用 axe-core。

### 查找元素

大多数元素都把角色放在其 shadow root 内的原生元素（`<button>`、`<input>`、`<dialog>`）上，而不是放在自身上，这样浏览器和测试工具看到的就是真实的语义；少数元素（例如标签或打开的菜单）由自身承载角色。Playwright 的定位器会查找开放的 shadow root 内部，因此请像人一样，按角色和名称查找控件：

```ts
await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByRole('switch', { name: 'Wi-Fi' })).toBeChecked();
```

点击这个按钮，也会触发注册在 `<tanrei-button>` 自身上的监听器。Testing Library 的查询不会查找 shadow root 内部，因此找不到这些控件。

### 角色与其内容位于不同的树中

有些元素把角色放在自己的 shadow root 中，而属于该角色的内容却是你自己的标记，通过插槽放入其中：`<tanrei-tabs>` 的标签列表及其 `<tanrei-tab>` 子元素，`<tanrei-dialog>` 的 `<dialog>` 及其操作按钮。在 DOM 中，标签并不在标签列表之内，因此从角色串联下去的定位器什么也找不到：

```ts
// 什么也找不到：标签是 <tanrei-tabs> 的子元素，而不是其标签列表的子元素。
page.getByRole('tablist', { name: '分区' }).getByRole('tab', { name: '账单' });
```

先找到持有该命名角色的元素，再在其中查找：

```ts
const sections = page.locator('tanrei-tabs', { has: page.getByRole('tablist', { name: '分区' }) });
await sections.getByRole('tab', { name: '账单' }).click();

const confirm = page.locator('tanrei-dialog', { has: page.getByRole('dialog', { name: '删除文件？' }) });
await confirm.getByRole('button', { name: '删除' }).click();
```

### 顶层中的菜单

打开期间，`<tanrei-menu>` 就是菜单，名称来自其锚点，但它在页面中不占空间：它的表面绘制在顶层中。因此，即使菜单已经打开，检查菜单本身是否可见也会失败。请先确认菜单存在，再检查其中的菜单项：

```ts
await page.getByRole('button', { name: '更多' }).click();
const menu = page.getByRole('menu', { name: '更多' });
await expect(menu).toBeAttached();
await expect(menu.getByRole('menuitem', { name: '重命名' })).toBeVisible();
```

关闭后，该元素不再是菜单，`await expect(menu).toHaveCount(0)` 会通过。

### 警告

在开发构建中，如果元素的用法不符合其规范，它会在控制台中说明，消息以 `[tanrei]` 开头，以行为的 ID 结尾。浏览器测试可以在出现任何一条时失败：[面向 AI 代理的指南](https://tanrei.chnotchy.com/zh-cn/getting-started/ai-agents/)中有 Playwright 的示例。
