---
title: "Web"
description: "Tanrei の CSS トークンと Web Components を、どの Web プロジェクトでも使えます。"
url: "https://tanrei.chnotchy.com/ja/getting-started/web/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Web

> Tanrei の CSS トークンと Web Components を、どの Web プロジェクトでも使えます。

`@tanrei/web` には 2 つのものが入っています。デザイントークンを CSS カスタムプロパティとして収めたスタイルシートと、Lit で作った標準準拠のカスタム要素です。特定のフレームワークは必要ありません。

> [!WARNING]
> **プレリリース**
>
> `0.1.0-next.0` を npm の dist-tag `next` で公開しています。`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
```

パックしたファイルには、リリースで公開するものがそのまま入っているので、以下の手順はすべて同じように動きます。ソースの新しい状態を取り込むには、pull してからビルドとパックをやり直し、新しいファイルをインストールします。

## 使い方

1. スタイルシートを一度だけ、できるだけ早い段階で読み込みます。どれもカスケードレイヤー（`tanrei.*`）に包まれているので、自分のスタイルが常に優先されます。

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

   `tanrei.css` は、すべてを 1 つにまとめたファイルです。ページのベース、トークン、要素なしで描くパーツ（テキストスタイル、テーブル、アコーディオンなど）のスタイルシートが入っています。使うものだけを読み込むなら、トークンと、必要ならベースを読み込み、それらのスタイルシートは要るものだけを足します。

   ```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';
   // または、要素を 1 つずつ:
   import '@tanrei/web/components/button';
   ```

   `@tanrei/web` が登録するのは既定のシステムが持つコンポーネントだけなので、その上のプロダクトから、システムにないコンポーネントには手が届きません。プリセットのエントリー `@tanrei/web/<id>` はそのプリセットが持つものを、`@tanrei/web/all` はパッケージのすべてのコンポーネントを登録します。[セグメントボタン](https://tanrei.chnotchy.com/ja/components/segmented-button/)のようにプリセットにしかないコンポーネントも、意図して使うことはできます。自分のシステムのレイヤーで取り込み、そのコンポーネント自身のモジュールを読み込みます。

   `@tanrei/web/…` という指定子はパッケージのエクスポートで、バンドラー（Vite、webpack、esbuild）が解決します。バンドラーがない場合、ブラウザーは `<link>` や `<script src>` の中の裸の指定子を解決できません。そのときはパッケージの `dist/` を配信して、ファイルを直接指定します。スタイルシートはそのまま、スクリプトは Lit とすべての要素を含む `tanrei.bundle.js` を使います。

   ```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. プロダクトの言葉を渡します。コンポーネントは自分の言葉を持ちません。必須・任意のタグのテキストや閉じるボタンの名前などは、プロダクトのものです。まずはすべてを平易な英語にするなら、英語の言葉のパックを一度だけ import します。

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

   どれかを変えるとき、または別の言語の言葉を渡すときは、そのあとで `setWords()` を呼びます。渡した言葉はパックの言葉に優先し、要素に書いた言葉はその両方に優先します。

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

   setWords({ requiredText: 'Mandatory' });
   setWords({ requiredText: '必須', optionalText: '任意', closeLabel: '閉じる' }, { lang: 'ja' });
   ```

   パックも自分の言葉もなくてもコンポーネントは動作し、どの言葉が足りないかをコンソールで知らせます。[言葉](https://tanrei.chnotchy.com/ja/foundations/words/)を参照してください。

## 要素のマニフェスト

エディターやコーディングエージェントは、パッケージの要素を [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest) の `@tanrei/web/custom-elements.json` から読みます。すべての要素の属性、プロパティ、イベント、スロット、パーツが載っていて、プロダクトから触れないものは載っていません。`@tanrei/web/custom-elements/<id>.json` にはシステム `<id>` が持つ要素だけが載っているので、1 つのシステムで使うツールはそれ以外を提案しません。

## テーマとコントラスト

トークンの値は `light-dark()` を使っているので、使われている要素の `color-scheme` に従います。何も設定しなければ OS の設定に従い、`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>` に付けます。あとから動くスクリプトで付けると、ページは最初の 1 フレームだけシステムのテーマで表示されます。`@tanrei/web/theme-script` はその両方をします。`themeScript()` は `<head>` のスタイルシートより前にインラインで書く小さなスクリプトで、`saveTheme()` は選んだテーマを保存してすぐに描きます。

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

saveTheme('dark'); // 'light'、'dark'、または OS に従い直す '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 }` を付けます。ルートにトークンを 1 つだけ `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` があります。次の 2 つの任意のスタイルシートを使うと、これらを要素に適用できます。

```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` は、置かれているロールを、その太さにファミリーの強調のステップを足した太さで描きます。これは太さであって、別のロールではありません。16 のロールと要素との対応は、[タイポグラフィ](https://tanrei.chnotchy.com/ja/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/ja/foundations/icons/)を参照してください。

Material Symbols Rounded のファイルは、すべてのグリフを、すべての太さ・塗り・グレード・オプティカルサイズで持っていて、5.4 MB あります。遅い回線では、ページがこれを 30 秒ほど待つことがあります。読み込むのは、製品が描くグリフだけにしてください。`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 で、公開するファイルが 1 つでもそれを超えるとビルドが失敗します。いちばん新しい構文はクラスの static ブロックで、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">
  // Safari の下限は light-dark() で、Chrome と Edge の下限は round() で、Firefox の下限は :state() で決まる
  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 の要素は、ブラウザーが実装している機能の上に作られています。シャドウルート、`ElementInternals`（フォームコントロールの値と妥当性、カスタムステート）、Popover API、`<dialog>`、そしてレイアウトです。jsdom と happy-dom はその一部しか実装していません（happy-dom には `attachInternals()` も Popover API もありません）。そのため、描画した時点で要素が例外を投げたり、拒否された Promise が残ったり、要素が前提にしているサイズやフォーカス順、トップレイヤーがないまま描画されたりします。Tanrei はこれらの環境をサポートしておらず、足りない機能の代わりになるもの（スタブ）も提供していません。要素が例外を投げないようにするだけの代用品を置くと、どのブラウザーにもない振る舞いでテストが通ってしまうからです。

Tanrei の要素を描画するテストは、実際のブラウザーで動かしてください。

- [Vitest のブラウザーモード](https://vitest.dev/guide/browser/)を Playwright プロバイダーで使えば、コンポーネントのテストを Vitest に置いたまま、Chromium、Firefox、WebKit で動かせます。
- [Playwright Test](https://playwright.dev/docs/intro) は、ページや一連の操作の流れを動かします。

Tanrei の要素を描画しない、自前のロジックのテストは、Node や jsdom、happy-dom のままでかまいません。Tanrei 自身のテストは、Chromium、Firefox、WebKit で、Playwright と axe-core を使って動かしています。

### 要素を探す

ほとんどの要素は、自分自身ではなくシャドウルートの中のネイティブ要素（`<button>`、`<input>`、`<dialog>`）にロールを持たせています。ブラウザーやテストツールに本物のセマンティクスを見せるためです。タブや開いているメニューなど、一部の要素は自分自身がロールを持ちます。Playwright のロケーターは開いたシャドウルートの中まで探すので、人と同じように、ロールと名前でコントロールを探してください。

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

そのボタンのクリックは、`<tanrei-button>` 自身に登録したリスナーにも届きます。Testing Library のクエリはシャドウルートの中を探さないので、これらのコントロールは見つかりません。

### ロールと中身が別のツリーにある場合

要素によっては、ロールを自分のシャドウルートに持ち、その中身はスロットで差し込まれた利用側のマークアップ、という形になっています。`<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 で終わります。ブラウザーのテストは、これが一つでも出たら失敗にできます。Playwright の例は [AI エージェント向けのガイド](https://tanrei.chnotchy.com/ja/getting-started/ai-agents/)にあります。
