Claude Codeでデザインシステムを構築する実践ガイド:Design Tokens、Storybook、CIまで
Claude CodeでDesign Tokens、React/TypeScript、Storybook、a11y・Visualテストをつなぐ実践的なデザインシステム構築ガイド。
デザインシステムは「UI部品集」ではなく変更に強い運用基盤
デザインシステムを作るとき、最初にボタンやカードを量産したくなります。けれど実務で効くのは、見た目の部品そのものよりも「色、余白、文字、状態、レビュー、テストをどう変え続けるか」です。
Claude Codeはこの作業と相性が良いです。既存コードを読み、複数ファイルをまたいで編集し、Storybookやテストを実行し、差分を見ながら修正できます。一方で、Figmaの意図、ブランド判断、アクセシビリティの最終責任まで丸投げすると失敗します。
この記事では、Design Tokens、React/TypeScriptコンポーネント、Storybook、アクセシビリティ、CIでのvisual/a11yチェック、Figma連携の現実的な境界を、Claude Codeに任せやすい粒度で整理します。
関連する個別テーマとして、トークン設計はClaude CodeでDesign Tokensを管理する実践ガイド、Storybookの基礎はClaude CodeでStorybook開発を効率化する方法、アクセシビリティの深掘りはClaude Codeでアクセシビリティ対応を効率化する方法も参照してください。
完成形の全体像
この記事の完成形は、次の流れです。
flowchart LR
Figma["Figma Variables"]
Tokens["tokens.json"]
Build["token build script"]
CSS["CSS variables"]
TS["TypeScript token map"]
Components["React components"]
Storybook["Storybook stories"]
CI["Visual and a11y CI"]
Figma -->|review input| Tokens
Tokens --> Build
Build --> CSS
Build --> TS
CSS --> Components
TS --> Components
Components --> Storybook
Storybook --> CI
設計の中心はtokens.jsonです。Figmaは重要な入力ですが、コード側の唯一の正本をどこに置くかを決めないまま双方向同期を始めると、デザイナーとエンジニアのどちらも安心して変更できません。
ここでいうDesign Tokensは、色や余白などの設計判断を名前付きのデータとして管理する仕組みです。例えばblue-600のような生の色ではなく、action.background.primaryのように意味を持つ名前で使うと、ブランド変更やダークモードでも壊れにくくなります。
最新仕様やツールの確認には、Design Tokens Community Group、Claude Code公式ドキュメント、Claude Code security、Storybook accessibility testing、Storybook visual tests、Playwright accessibility testing、Figma REST APIを確認します。この記事では特定バージョンに依存しすぎない書き方にしています。
Claude Codeに任せる作業粒度
Claude Codeに向いているのは、境界が明確で、実行結果を検証できる作業です。逆に「いい感じにデザインシステムを作って」だけでは、トークン名、対象コンポーネント、アクセシビリティ基準、既存CSSとの関係が曖昧になり、レビュー不能な巨大差分になりがちです。
おすすめの依頼粒度は次の通りです。
| 作業 | Claude Codeに任せる範囲 | 人が判断する範囲 |
|---|---|---|
| トークン整備 | 既存CSSから色、余白、角丸を抽出し、候補を表にする | ブランド上の採用可否、命名原則 |
| コンポーネント化 | Button、Input、Alertなどを型付きで実装する | どの状態を公開APIにするか |
| Storybook | variants、states、interaction storyを追加する | 実際の画面で必要な組み合わせ |
| a11y確認 | aria-*、keyboard、axeの失敗を検出する | UX上の許容判断、手動確認 |
| CI整備 | visual/a11yテストをPRで走らせる | ブロック条件、例外承認 |
実務では、先にCLAUDE.mdや作業メモに次のようなルールを書いてから依頼すると安定します。
Design system task rules:
- Edit only files under src/components, src/styles, .storybook, tests, and tokens.json.
- Do not change brand colors without listing the old and new token names.
- Every new component must include TypeScript props, keyboard behavior, Storybook stories, and a11y notes.
- Run npm run tokens:build, npm run test:storybook, and npm run test:a11y before reporting done.
- If a change touches focus behavior, include manual review steps.
Claude Codeの権限も重要です。公式ドキュメントでは権限ベースの設計やセキュリティ境界が説明されていますが、実務ではさらに「Figmaトークンを書き戻さない」「.envを読ませない」「CI secretsを表示しない」「大量のスナップショット更新は人が承認する」といったプロジェクト固有の制限を明文化します。
Masaが小規模SaaSのUI整理で試したとき、最初は「既存ボタンを全部置き換えて」と依頼して差分が大きくなりすぎました。うまくいったのは、まず既存ボタンのpropsと利用箇所を一覧化し、次にButtonだけをStorybook付きで置き換え、最後に画面単位で移行する進め方でした。Claude Codeは加速装置ですが、変更範囲を切る設計は人間の仕事です。
最小セットアップ
React、TypeScript、Tailwind CSS相当のユーティリティクラスを前提にします。すでにプロジェクトがある場合は、パッケージ名だけ読み替えてください。
npm install class-variance-authority clsx tailwind-merge
npm install -D @storybook/react-vite @storybook/addon-a11y @storybook/test-runner @playwright/test @axe-core/playwright concurrently http-server wait-on
npx storybook init
npx playwright install chromium
package.jsonには、最低限このスクリプトを用意します。
{
"scripts": {
"tokens:build": "node scripts/build-tokens.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test:storybook": "test-storybook --url http://127.0.0.1:6006",
"test:a11y": "playwright test tests/a11y.spec.ts",
"test:visual": "playwright test tests/button.visual.spec.ts"
}
}
Design Tokensを正本にする
最初に、プリミティブ、セマンティック、コンポーネントの3層でトークンを分けます。プリミティブは素の値、セマンティックは意味、コンポーネントは特定UIの用途です。
{
"primitive": {
"color": {
"blue": {
"50": { "$type": "color", "$value": "#eff6ff" },
"600": { "$type": "color", "$value": "#2563eb" },
"700": { "$type": "color", "$value": "#1d4ed8" }
},
"gray": {
"50": { "$type": "color", "$value": "#f9fafb" },
"200": { "$type": "color", "$value": "#e5e7eb" },
"900": { "$type": "color", "$value": "#111827" }
},
"red": {
"600": { "$type": "color", "$value": "#dc2626" },
"700": { "$type": "color", "$value": "#b91c1c" }
},
"white": { "$type": "color", "$value": "#ffffff" }
},
"space": {
"2": { "$type": "dimension", "$value": "0.5rem" },
"3": { "$type": "dimension", "$value": "0.75rem" },
"4": { "$type": "dimension", "$value": "1rem" },
"6": { "$type": "dimension", "$value": "1.5rem" }
},
"radius": {
"md": { "$type": "dimension", "$value": "0.375rem" },
"lg": { "$type": "dimension", "$value": "0.5rem" }
}
},
"semantic": {
"color": {
"surface": { "$type": "color", "$value": "{primitive.color.white}" },
"text": { "$type": "color", "$value": "{primitive.color.gray.900}" },
"border": { "$type": "color", "$value": "{primitive.color.gray.200}" },
"focus": { "$type": "color", "$value": "{primitive.color.blue.600}" }
}
},
"component": {
"button": {
"primary": {
"background": { "$type": "color", "$value": "{primitive.color.blue.600}" },
"backgroundHover": { "$type": "color", "$value": "{primitive.color.blue.700}" },
"text": { "$type": "color", "$value": "{primitive.color.white}" }
},
"danger": {
"background": { "$type": "color", "$value": "{primitive.color.red.600}" },
"backgroundHover": { "$type": "color", "$value": "{primitive.color.red.700}" },
"text": { "$type": "color", "$value": "{primitive.color.white}" }
}
}
}
}
次にCSS変数とTypeScript定数を生成します。
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
const source = JSON.parse(readFileSync("tokens.json", "utf8"));
function getToken(path) {
const node = path.split(".").reduce((current, key) => current?.[key], source);
if (!node || typeof node.$value === "undefined") {
throw new Error(`Unknown token reference: ${path}`);
}
return node.$value;
}
function resolveValue(value) {
if (typeof value === "string" && value.startsWith("{") && value.endsWith("}")) {
return resolveValue(getToken(value.slice(1, -1)));
}
return value;
}
function walk(node, pathParts = [], result = {}) {
if (node && typeof node === "object" && typeof node.$value !== "undefined") {
const name = pathParts.join("-");
result[name] = resolveValue(node.$value);
return result;
}
for (const [key, value] of Object.entries(node)) {
walk(value, [...pathParts, key], result);
}
return result;
}
const flat = walk(source);
const css = [
":root {",
...Object.entries(flat).map(([name, value]) => ` --${name}: ${value};`),
"}",
""
].join("\n");
mkdirSync(dirname("src/styles/tokens.css"), { recursive: true });
mkdirSync(dirname("src/tokens.ts"), { recursive: true });
writeFileSync("src/styles/tokens.css", css);
writeFileSync("src/tokens.ts", `export const tokens = ${JSON.stringify(flat, null, 2)} as const;\n`);
console.log(`Generated ${Object.keys(flat).length} tokens.`);
このスクリプトはnode scripts/build-tokens.mjsで動きます。Claude Codeには「既存CSSの色を抽出してtokens.jsonの候補に寄せる。ただし既存画面の見た目を変える差分は別PRに分ける」と依頼すると、レビューしやすい単位になります。
React/TypeScriptコンポーネントを型で守る
次にButtonを作ります。ここでは実務で使いやすいように、variant、size、loading、disabled、focus ringを含めています。React 19ではrefの扱いが変わっていくため、既存のReact 18系プロジェクトやライブラリ互換ではforwardRef、新規方針ではrefをpropとして渡す設計を検討します。
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2 rounded-md font-medium",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-[var(--semantic-color-focus)] focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50"
],
{
variants: {
variant: {
primary: [
"bg-[var(--component-button-primary-background)]",
"text-[var(--component-button-primary-text)]",
"hover:bg-[var(--component-button-primary-backgroundHover)]"
],
secondary: "border border-[var(--semantic-color-border)] bg-[var(--semantic-color-surface)] text-[var(--semantic-color-text)] hover:bg-gray-50",
danger: [
"bg-[var(--component-button-danger-background)]",
"text-[var(--component-button-danger-text)]",
"hover:bg-[var(--component-button-danger-backgroundHover)]"
]
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base"
}
},
defaultVariants: {
variant: "primary",
size: "md"
}
}
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, size, loading = false, disabled, children, ...props },
ref
) {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
/>
) : null}
<span>{children}</span>
</button>
);
});
レビュー観点は、見た目よりもAPIの固定化です。variant="warning"を入れる前に、プロダクトで本当に必要か、dangerとの違いは何か、a11y上の文言やアイコンに依存しないかを確認します。
Storybookを仕様書にする
Storybookは「部品一覧」ではなく、状態を固定した仕様書として使います。状態がStoryにないものは、レビューもvisual testもできません。
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta = {
title: "Design System/Button",
component: Button,
parameters: {
layout: "centered",
a11y: {
test: "error"
}
},
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "danger"]
},
size: {
control: "select",
options: ["sm", "md", "lg"]
},
loading: { control: "boolean" },
disabled: { control: "boolean" }
}
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
children: "保存する",
variant: "primary"
}
};
export const Danger: Story = {
args: {
children: "削除する",
variant: "danger"
}
};
export const Loading: Story = {
args: {
children: "保存中",
loading: true
}
};
export const AllStates: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="md">Medium</Button>
<Button variant="primary" size="lg">Large</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
</div>
)
};
Claude Codeには「Buttonの公開propsを読み、全variant、全size、loading、disabled、keyboard focusが見えるStoryを追加して」と依頼します。さらに「既存Storyを消さない」「snapshot名を変える場合は理由を書く」を加えると、CIの差分が追いやすくなります。
CIでvisualとa11yを落とす
アクセシビリティは手動確認が必須ですが、明らかな違反はCIで止めるべきです。Storybookのa11y addonはaxe-coreを利用し、CIでも失敗にできます。Playwright側でも主要Storyを直接開いて検査します。
import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const storyPaths = [
"/iframe.html?id=design-system-button--primary",
"/iframe.html?id=design-system-button--danger",
"/iframe.html?id=design-system-button--loading",
"/iframe.html?id=design-system-button--all-states"
];
for (const storyPath of storyPaths) {
test(`a11y ${storyPath}`, async ({ page }) => {
await page.goto(`http://127.0.0.1:6006${storyPath}`);
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});
}
visual regressionは、Storybookの全Storyを対象にする方法と、重要StoryだけをPlaywright screenshotで固定する方法があります。最初は重要Storyに絞るほうが、ノイズが少なく運用できます。
import { expect, test } from "@playwright/test";
test("button all states visual snapshot", async ({ page }) => {
await page.goto("http://127.0.0.1:6006/iframe.html?id=design-system-button--all-states");
await expect(page).toHaveScreenshot("button-all-states.png", {
fullPage: true,
animations: "disabled"
});
});
GitHub Actionsでは次のように、トークン生成、Storybook build、Storybook test、Playwright a11y、visualをまとめます。
name: design-system-quality
on:
pull_request:
paths:
- "tokens.json"
- "scripts/build-tokens.mjs"
- "src/components/**"
- "src/styles/**"
- ".storybook/**"
- "tests/**"
- "package.json"
- "package-lock.json"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run tokens:build
- run: npm run build-storybook
- run: npx playwright install --with-deps chromium
- run: >
npx concurrently -k -s first -n server,tests
"npx http-server storybook-static -p 6006"
"npx wait-on http://127.0.0.1:6006 && npm run test:storybook && npm run test:a11y && npm run test:visual"
失敗時にClaude Codeへ渡すログは、全文ではなく「失敗したStory ID、差分画像、axe violation、直近の変更ファイル」に絞ります。CIログ全体を貼るより、再現条件が明確なほうが修正精度が上がります。
Figma連携の現実的な境界
Figma Variablesはデザイン側の運用には非常に有効です。ただし、Figmaから取得した値をそのまま本番トークンへ上書きする運用は危険です。命名、テーマ、非公開実験、未承認の色、古いページの残骸が混ざることがあるからです。
現実的には、次の境界にすると安定します。
| 連携対象 | 自動化してよいこと | 自動化しないこと |
|---|---|---|
| Figma Variables | JSON exportを読み、差分候補を出す | 本番tokens.jsonへの無審査上書き |
| Figma Components | props候補や状態名を拾う | React APIを自動決定する |
| Figma comments | 未解決論点を一覧にする | デザイナーの意図を推測して確定する |
| Storybook links | Story URLをFigmaに貼る | Storybookをデザインレビューの代替にする |
Claude Codeへの依頼はこのくらい具体的にします。
Read figma-tokens-export.json and tokens.json.
Create a markdown report with:
1. tokens that exist in Figma but not in code
2. tokens that exist in code but not in Figma
3. value differences for matching semantic tokens
Do not edit tokens.json. Do not rename tokens. Mark risky differences around focus, danger, and text color.
Figma連携の目的は、同期そのものではなく、レビュー可能な差分を作ることです。デザインシステムの正本がFigmaなのかコードなのか、あるいはToken Studioのような中間ツールなのかを決めてから自動化します。
4つの実例とユースケース
1つ目はSaaS管理画面です。ボタン、フォーム、テーブル、モーダルの状態が多く、画面ごとに色や余白がズレやすい領域です。Claude CodeにはまずButtonとInputの利用箇所を一覧化させ、propsの互換層を作ってから画面単位で置き換えると安全です。
2つ目は複数ブランドのホワイトラベルUIです。プリミティブ色をブランドごとに持ち、セマンティックトークンを共通にすると、action.background.primaryのような意味は維持したまま配色だけ差し替えられます。Claude Codeにはブランド別CSS変数の生成とStorybookのテーマ切り替えを任せます。
3つ目はレガシーCSSの整理です。#2563eb、blue-600、primaryが混在している状態から、トークン候補を抽出し、重複を表にする作業はClaude Codeが得意です。ただし、見た目を変える置換は一括ではなく、visual snapshotを見ながら小さく進めます。
4つ目として、問い合わせ増加を狙うマーケティングサイトにも効きます。CTAボタン、料金カード、フォームの見た目が記事やLPごとに違うと信頼が落ちます。デザインシステム化により、CTAの文言テストやフォーム改善を安全に回せます。
具体的な失敗例と落とし穴
よくある失敗は、プリミティブトークンだけを増やし続けることです。blue-500やblue-600を直接コンポーネントから参照すると、後でブランド変更したときに意味が分かりません。コンポーネントはできるだけセマンティックまたはコンポーネントトークンを参照します。
次の落とし穴は、Storybookを作っただけで満足することです。StoryがCIに接続されていなければ、壊れても気づきません。最低限、PRでStorybook buildとa11y checkを走らせます。
visual testのノイズも現実的な問題です。アニメーション、外部フォント、日付、ランダムIDが入ると差分が毎回出ます。animations: "disabled"を使い、日付表示を固定し、スクリーンショット対象を重要Storyに絞ります。
アクセシビリティでは、axeで通ったから完了という誤解が危険です。自動テストはラベル欠落やコントラストなどを検出できますが、文脈に合う文言、キーボード操作の自然さ、スクリーンリーダーでの理解しやすさは人が確認します。
Claude Codeへの依頼が大きすぎるのも失敗要因です。「全コンポーネントをデザインシステム化して」は避けます。「src/components/Buttonだけを対象に、既存API互換を保ち、Storyとa11y testを追加して」のように切ります。
最後にセキュリティです。Figma API token、npm token、Chromatic token、GitHub tokenをログや記事に出してはいけません。Claude Codeにログを渡す前にsecretをマスクし、.envやCI設定の読み取り範囲を必要最小限にします。
レビュー観点チェックリスト
公開前、またはPR前には次を確認します。
- トークン名が「見た目」ではなく「意味」で使われているか
- コンポーネントpropsが増えすぎていないか
- disabled、loading、error、focus、hoverの状態がStoryにあるか
- キーボードだけで操作できるか
aria-*が不足または過剰になっていないか- visual snapshotの差分を人が確認したか
- Figmaとの差分がレビュー資料として残っているか
- Claude Codeが触ったファイル範囲が依頼範囲内か
- secretsや社内情報がプロンプト、ログ、Storybookに混ざっていないか
このチェックをCLAUDE.mdに入れておくと、Claude Codeが次回以降の作業でも同じ観点を参照しやすくなります。
この記事で紹介した内容を実際に試すときの確認ポイント
まずtokens.jsonからCSS変数とTypeScript定数が生成できること、次にButtonのStoryが全状態を表示できること、最後にCIでStorybook build、a11y、visual testが同じコマンドで再現できることを確認してください。Figma連携は最初から同期を狙わず、差分レポート作成までに留めると安全です。
Claude Codeを使ったデザインシステム整備、Storybook導入、アクセシビリティレビュー、既存UIの棚卸しで詰まっている場合は、研修・相談ページから相談できます。単発のコードレビューから、チーム向けの運用ルール作成、実装支援まで対応できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。