デザイントークン設計の始め方:CSS変数とStyle Dictionaryでダークモードまで一本化
デザイントークンとは何かを最小例から解説。プリミティブ→セマンティックの設計、CSS変数での実装、Style Dictionaryの多形式出力、ダークモード切替まで僕の失敗込みで。
「この青、ちょっと濃くして」
デザイナーからそう言われて、僕はプロジェクトを #2563eb で検索しました。47件。ボタン、リンク、バッジ、グラフの線、メールのテンプレ。ひとつずつ直して、3件見落として、本番のフッターだけ古い青のまま残りました。
色を1つ変えるのに、なぜこんなに手間がかかるのか。原因は単純で、「ブランドの青」という意味を、コードのどこにも書いていなかったからです。書いてあったのは #2563eb という生の値だけ。値は検索できても、「これは何のための色か」は検索できません。
この「意味」を名前付きのデータとして外に出したものが、デザイントークンです。今日はこれを、最小の例から組み立てていきます。
この記事の要点
- デザイントークンは色・余白・文字サイズなどの「デザイン判断」に名前を付けて1か所にまとめたデータ。値そのものではなく意味を管理する
- 設計の肝は2層に分けること。生の値を持つプリミティブ(
color.blue.600)と、用途を表すセマンティック(color.action.primary.bg)。コンポーネントが触るのはセマンティックだけ - 実装はCSS変数(
var(--...))。ブラウザ標準で、テーマ切替もこれだけで済む - 1つのトークン定義からCSSもTypeScriptもiOS用も吐き出すのがStyle Dictionary(現在のメジャー版はv5)
- ダークモードは「CSSを複製」ではなく「セマンティックトークンの値だけ差し替え」。コンポーネントは1行も変えない
デザイントークンとは、要するに何か
トークンは「デザイン判断の台帳」です。
たとえば家のリフォームを思い浮かべてください。壁の色を「ベージュ」と紙に書いておけば、職人さんが何人いても、来年塗り直すときも、同じベージュで揃います。誰かが現場で「えーと、たしかこんな感じの色」と目分量で塗り始めたら、部屋ごとに微妙に違うベージュができあがる。トークンがない状態は、まさにこの「目分量のベージュ」です。
コードでいうと、tokens.json のような1つのファイルに、こう書いておきます。
| 何を | トークン名の例 | 値の例 |
|---|---|---|
| ブランドの青 | color.blue.600 | #2563eb |
| ボタンの背景 | color.action.primary.bg | (上の青を参照) |
| カードの余白 | space.4 | 1rem |
| 見出しの文字サイズ | font.size.lg | 1.125rem |
ポイントは、color.action.primary.bg が生の色ではなく color.blue.600 を参照しているところです。冒頭の僕の事故は、ボタンが直接 #2563eb を見ていたから起きました。間に「ボタンの背景という意味」を1枚はさんでおけば、青を緑に変える日が来ても、書き換えるのは台帳の1行だけで済みます。
なぜ今これが効くのか。AIにUIをいじらせる場面が増えたからです。Claude Codeに「ボタンの色を整えて」と頼むと、トークンがなければハードコードされた色を探して回り、見落とします。台帳が1つあれば、AIに渡すのも「この1ファイルを直して」で済む。人間が探して回る作業を、構造で消せるわけです。
プリミティブとセマンティックを分ける
トークン設計でいちばん最初にやる判断が、この2層分けです。ここを雑にやると、あとで全部やり直しになります。僕は一度やり直しました。
- プリミティブトークン(raw): 値に近い名前。
color.blue.600、space.4、font.size.lg。色見本帳・素材のカタログにあたる。 - セマンティックトークン: 用途を表す名前。
color.action.primary.bg、surface.default、text.muted。「どこで使うか」を表す。
たとえやすい言い方をすると、プリミティブは絵の具のチューブ、セマンティックは**「壁用」「ドア用」というラベルを貼った塗料缶**です。チューブの「コバルトブルー」を直接壁に塗ると、来年「やっぱり壁は緑」となったとき、壁を探して塗り直すしかない。でも「壁用」の缶の中身を緑に詰め替えれば、塗る側(コンポーネント)は何も知らずに緑になります。
| 種類 | 例 | 役割 |
|---|---|---|
| プリミティブ | color.blue.600, space.4 | 色階調・余白スケールの定義(素材) |
| セマンティック | color.action.primary.bg, text.muted | ボタン・背景・文字など用途の定義(使い道) |
| コンポーネント | button.primary.paddingX | 特定部品だけの細かい調整(最後の手段) |
鉄則は1つ。コンポーネントのCSSやReactからは、セマンティックトークンしか参照しない。blue-600 を直接ボタンに書いた瞬間、「青ではないのにblue」という名前が将来必ず残ります。
3つ目の「コンポーネントトークン」は、最初は作らないでください。小さなプロダクトはプリミティブとセマンティックの2層で十分です。複数ブランドや細かい部品差が出てきて、初めて足す。最初から button-primary-large-desktop-padding-left のような名前を量産すると、誰も覚えられない台帳ができあがります(これも僕がやらかしました。後述します)。
実行できる tokens.json を書く
理屈はここまで。実際に動く台帳を作ります。次を tokens/tokens.json として保存してください。色、余白、文字、角丸、影、そしてライト/ダークの両方を含めた最小セットです。
{
"color": {
"blue": {
"50": { "$type": "color", "$value": "#eff6ff" },
"600": { "$type": "color", "$value": "#2563eb" },
"700": { "$type": "color", "$value": "#1d4ed8" }
},
"slate": {
"50": { "$type": "color", "$value": "#f8fafc" },
"100": { "$type": "color", "$value": "#f1f5f9" },
"700": { "$type": "color", "$value": "#334155" },
"900": { "$type": "color", "$value": "#0f172a" }
},
"white": { "$type": "color", "$value": "#ffffff" },
"focus": { "$type": "color", "$value": "#f59e0b" },
"surface": {
"default": { "$type": "color", "$value": "{color.white}" },
"muted": { "$type": "color", "$value": "{color.slate.50}" }
},
"text": {
"default": { "$type": "color", "$value": "{color.slate.900}" },
"muted": { "$type": "color", "$value": "{color.slate.700}" }
},
"action": {
"primary": {
"bg": { "$type": "color", "$value": "{color.blue.600}" },
"bgHover": { "$type": "color", "$value": "{color.blue.700}" },
"text": { "$type": "color", "$value": "{color.white}" }
}
}
},
"dark": {
"color": {
"surface": {
"default": { "$type": "color", "$value": "{color.slate.900}" },
"muted": { "$type": "color", "$value": "{color.slate.700}" }
},
"text": {
"default": { "$type": "color", "$value": "{color.white}" },
"muted": { "$type": "color", "$value": "{color.slate.100}" }
},
"action": {
"primary": {
"bg": { "$type": "color", "$value": "{color.blue.50}" },
"bgHover": { "$type": "color", "$value": "{color.white}" },
"text": { "$type": "color", "$value": "{color.slate.900}" }
}
}
}
},
"space": {
"2": { "$type": "dimension", "$value": "0.5rem" },
"3": { "$type": "dimension", "$value": "0.75rem" },
"4": { "$type": "dimension", "$value": "1rem" },
"6": { "$type": "dimension", "$value": "1.5rem" }
},
"font": {
"size": {
"sm": { "$type": "dimension", "$value": "0.875rem" },
"base": { "$type": "dimension", "$value": "1rem" },
"lg": { "$type": "dimension", "$value": "1.125rem" }
},
"weight": {
"medium": { "$type": "fontWeight", "$value": "500" },
"bold": { "$type": "fontWeight", "$value": "700" }
}
},
"radius": {
"md": { "$type": "dimension", "$value": "0.5rem" },
"lg": { "$type": "dimension", "$value": "0.75rem" }
},
"shadow": {
"button": { "$type": "shadow", "$value": "0 1px 2px rgb(15 23 42 / 0.16)" }
}
}
見てほしいのは {color.blue.600} という波カッコの参照です。これが「壁用の缶に絵の具を詰める」操作にあたります。$type と $value という書き方は、W3CのDesign Tokens Format Moduleという標準仕様に沿った形です。自己流のJSONでも動きますが、標準に寄せておくとツール間の引っ越しがラクになります。
CSS変数とは何か、そしてなぜトークンの実装先に選ぶのか
トークンは「定義」です。それをブラウザで使える形にする受け皿が、CSS変数(CSSカスタムプロパティ)です。
CSS変数は、こう書いて、
:root {
--color-action-primary-bg: #2563eb;
}
こう使う、ブラウザ標準の仕組みです。
.button {
background: var(--color-action-primary-bg);
}
何がうれしいか。--color-action-primary-bg の値を後から差し替えると、それを var() で参照している全箇所が一斉に変わります。JavaScriptもビルドも要りません。html に data-theme="dark" を1つ付けるだけで、同じボタンが別の色になる。ダークモードがこれだけで実現するのは、この「一斉差し替え」のおかげです。SassやLessの変数と違って、ブラウザが実行時に解決してくれる点がポイントです(詳しい仕様はMDNのCSSカスタムプロパティを参照)。
つまり、トークンの値をCSS変数に流し込めば、「台帳を1か所いじると画面全体が追従する」状態ができあがります。問題は、tokens.json から手でCSS変数を書き写すのは事故のもとだということ。そこを自動化するのが次のStyle Dictionaryです。
Style Dictionary で多形式に書き出す
Style Dictionaryは、1つのトークン定義からCSS変数、TypeScript、iOS、Androidなど複数の形式を吐き出すビルドツールです。現在のメジャー版はv5。「台帳1つ → 各プラットフォーム」の変換工場だと思ってください。
まず入れます。
npm install -D style-dictionary
style-dictionary.config.js を作ります。ライトとダークを1つのCSSにまとめる、自前のフォーマットを登録しています。
export default {
source: ["tokens/tokens.json"],
hooks: {
formats: {
"css/variables-with-dark": ({ dictionary }) => {
// dark配下を除いた通常トークンを :root へ
const light = dictionary.allTokens
.filter((token) => token.path[0] !== "dark")
.map((token) => ` --${token.name}: ${token.value};`)
.join("\n");
// dark配下だけ取り出し、先頭の "dark" を外して同じ変数名にそろえる
const dark = dictionary.allTokens
.filter((token) => token.path[0] === "dark")
.map((token) => ` --${token.path.slice(1).join("-")}: ${token.value};`)
.join("\n");
return `:root {\n${light}\n}\n\n[data-theme="dark"] {\n${dark}\n}\n`;
},
},
},
platforms: {
css: {
transformGroup: "css",
buildPath: "src/styles/",
files: [{ destination: "tokens.css", format: "css/variables-with-dark" }],
},
},
};
package.json にビルド用のスクリプトを足します。
{
"scripts": {
"tokens:build": "style-dictionary build --config style-dictionary.config.js"
}
}
npm run tokens:build を実行すると、src/styles/tokens.css が生成されます。中身はこうなります。
:root {
--color-blue-50: #eff6ff;
--color-blue-600: #2563eb;
--color-action-primary-bg: #2563eb;
--color-action-primary-bg-hover: #1d4ed8;
--color-action-primary-text: #ffffff;
--color-surface-default: #ffffff;
--color-text-default: #0f172a;
--space-4: 1rem;
--font-size-base: 1rem;
--radius-md: 0.5rem;
--shadow-button: 0 1px 2px rgb(15 23 42 / 0.16);
}
[data-theme="dark"] {
--color-surface-default: #0f172a;
--color-text-default: #ffffff;
--color-action-primary-bg: #eff6ff;
--color-action-primary-text: #0f172a;
}
ダーク側は surface.default や action.primary.bg のような用途トークンだけを上書きしています。プリミティブの blue.600 はそのまま。だからボタンの実装は1行も変わらず、テーマだけ増えます。
ダークモードとテーマ切替を1行で動かす
生成された tokens.css を読み込み、テーマを切り替えるところまで通します。HTMLとCSSはこれだけです。
@import "./styles/tokens.css";
.button {
background: var(--color-action-primary-bg);
border: 0;
border-radius: var(--radius-md);
box-shadow: var(--shadow-button);
color: var(--color-action-primary-text);
cursor: pointer;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
padding: var(--space-3) var(--space-4);
}
.button:hover {
background: var(--color-action-primary-bg-hover);
}
/* キーボード操作の人のために、focusの輪郭は必ず残す */
.button:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
body {
background: var(--color-surface-default);
color: var(--color-text-default);
}
テーマ切替は <html> の属性を1つ付け替えるだけ。
// 押すたびにライト/ダークを行き来する最小トグル
const root = document.documentElement;
document.querySelector("#theme-toggle").addEventListener("click", () => {
const next = root.dataset.theme === "dark" ? "light" : "dark";
root.dataset.theme = next;
localStorage.setItem("theme", next); // 次回の訪問でも覚えておく
});
// 起動時に前回の選択を復元
root.dataset.theme = localStorage.getItem("theme") ?? "light";
button のCSSにはダークモード用の記述が1文字もありません。それでも色が切り替わるのは、参照している --color-action-primary-bg の中身が [data-theme="dark"] で差し替わるからです。これが「セマンティックトークン + CSS変数」の効きどころです。コンポーネントを増やしても、テーマ対応は台帳側だけで完結します。
なお、ここで :focus-visible の輪郭を消さないこと。色を整える作業のついでに outline: none を入れてしまうと、キーボードだけで操作する人が「今どこにいるか」を見失います。アクセシビリティ全般の進め方はClaude Codeでアクセシビリティ対応を効率化する方法にまとめています。コントラスト比は通常テキストで4.5:1以上が目安で、ダークやhoverの色を足すたびに確認するのが安全です。
僕がトークン設計でやらかした失敗3つ
正直に書きます。最初に作ったトークンは、ほとんど作り直しになりました。
ひとつ目は、最初から細かくしすぎたこと。「将来の柔軟性のため」と思って button-primary-large-desktop-padding-left みたいな名前を数十個用意したら、誰も(自分すら)どれを使えばいいか分からなくなりました。今は色・余白・文字・角丸・影の基本スケールと、ボタン/背景/文字くらいのセマンティックから始めています。足りなくなってから足すほうが、ずっと速い。
ふたつ目は、名前に見た目を入れたこと。blueButton、grayText と名付けたら、ブランドカラーを変えた瞬間に「青くないblueButton」が大量発生しました。primaryAction、textMuted のように用途で名付けていれば、中身の色が変わっても名前は生き残ります。
みっつ目は、生成されたCSSを手で直したこと。src/styles/tokens.css はビルドの出力なのに、急ぎで直接1行いじったら、次の tokens:build できれいに上書きされて消えました。当然です。変更は必ず tokens/tokens.json に戻して再生成する。出力ファイルはGitで管理しても「人が編集する場所ではない」と全員に周知しておくのがいいです。
Claude Code に安全に任せるコツ
トークン化はClaude Codeと相性がいい作業です。台帳が「読めるデータ」だからです。ただし「いい感じにデザインシステム化して」と丸投げすると、表面だけ整った巨大な差分が返ってきます。順番を決めて渡すのがコツです。
- まず読ませる範囲を絞る(
tokens/tokens.jsonと対象コンポーネントだけ) - 「ファイルを編集する前に、変更点の差分表だけ出して」と頼む
- 命名と影響範囲を人間が確認してから、編集を許可する
- 仕上げに
npm run tokens:buildと対象コンポーネントのテストを走らせる
そのまま使えるレビュー依頼文を置いておきます。
デザイントークンのレビュー依頼:
- tokens/tokens.json, style-dictionary.config.js, src/styles/tokens.css, src/components/Button.css を読む。
- コンポーネントがセマンティックトークンを使い、プリミティブの色を直接使っていないか確認する。
- ライト/ダーク両方で、ボタン・背景・文字の値が破綻していないか確認する。
- 手で編集された生成CSSがあれば指摘する。
- 最小の安全な差分を提案する。トークンの改名は、影響する全コンポーネントを列挙できる場合のみ行う。
- 変更後は npm run tokens:build と対象テストを実行する。
「作業範囲を狭く・確認コマンドを明示・観点は箇条書き」。この3点を守るだけで、AIの出力が「見た目の修正」から「再現できる変更管理」に変わります。トークンより上の層、つまりボタンそのものの粒度やバリアント設計、Storybookでの仕様化やチーム運用はデザインシステムをコンポーネント設計から小さく育てる手順に分けて書きました。値の層(この記事)と部品の層(姉妹記事)はセットで読むと地図がつながります。CSS変数そのものの実務テクニックはClaude CodeでCSS変数を実務投入する設計ガイドが詳しいです。
よくある質問
Q. デザイントークンとCSS変数は何が違うんですか? A. レイヤーが違います。デザイントークンは「ブランドの青はボタン背景に使う」という意味の定義。CSS変数はそれをブラウザで動かす実装手段の1つです。トークンをCSS変数、TypeScript、iOS用などに変換して使います。トークン=設計、CSS変数=実装先、と覚えると混乱しません。
Q. Style Dictionaryは必須ですか?手書きじゃダメ? A. プロジェクトがCSSだけなら、最初は手書きのCSS変数で十分です。Style Dictionaryが効くのは、同じトークンをCSSとアプリ(iOS/Android)やJSの両方に配りたいとき、そして手書きの転記ミスをなくしたいとき。Web1枚で完結するうちは、無理に入れなくて大丈夫です。
Q. ダークモードのためにCSSを2つ書く必要がありますか?
A. いりません。コンポーネントのCSSは1つのままで、[data-theme="dark"] の中でセマンティックトークンの値だけを差し替えます。var(--color-surface-default) を参照していれば、属性を切り替えるだけで全体が追従します。
Q. プリミティブとセマンティック、両方作るのは面倒では? A. 最初は面倒に感じます。でも色を1回変えるだけで元が取れます。冒頭の僕のように47箇所を手で直す未来と、台帳1行を直す未来を比べてください。小さく始めるなら、まずよく使う色5〜10個と、ボタン/背景/文字のセマンティックだけでいいです。
Q. 既存プロジェクトに後から入れられますか?
A. 入れられます。一気に全部やらず、ボタン・リンク・カードの3部品だけ選んで色と余白をセマンティックトークンに置き換えるのが現実的です。Claude Codeに既存CSSを読ませ、重複する #2563eb や 16px を洗い出させると、置換の出発点が作れます。
実際に試した結果
この記事の流れを、実際に小さなReact環境で通しました。tokens.json を直して npm run tokens:build を走らせると、ボタンの背景・文字・focus輪郭、そしてダーク時の値までが一括で生成され、data-theme の付け替えだけでテーマが切り替わりました。
いちばん効いたのは、プリミティブとセマンティックを最初に分けておいたことです。後日「primaryをもう少し落ち着いた青に」と言われたとき、触ったのは color.blue.600 の1行だけ。検索して回る作業も、見落としによる本番の残骸も、両方ゼロになりました。冒頭の47箇所事件のときの自分に、この台帳を渡してやりたい、というのが正直な感想です。
まずは手元のプロジェクトから、ボタン・リンク・カードの3つだけ選んでトークン化してみてください。手順をなぞるテンプレートやチェックリストはClaudeCodeLabの教材一覧に、チーム導入やレビュー設計の相談は研修・相談にまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。