Advanced (更新: 2026/7/22)

用 Claude Code 搭建设计系统:Design Tokens、Storybook 与 CI 实战

从设计令牌到 Storybook、无障碍与 CI,学习如何让 Claude Code 安全地协助搭建设计系统。

用 Claude Code 搭建设计系统:Design Tokens、Storybook 与 CI 实战

先别急着做按钮:设计系统最容易失败在“改不动”

团队第一次搭建设计系统时,常见做法是先做一批看起来整齐的按钮、卡片和表单。几周后,品牌色要调整、禁用状态要统一、焦点样式要补齐,大家才发现同一个颜色散落在几十个文件里,Storybook 也没有覆盖真实状态。组件虽然存在,却没有一套可以持续修改和验证的工作方式。

设计系统不是组件陈列室,而是一套运营模型:颜色、间距、字体、状态、评审和测试如何从一个决定稳定地传到产品界面。Claude Code 能读取现有代码、跨文件修改、执行 Storybook 和测试并汇报差异,因此适合处理边界清楚、结果可验证的工作。但品牌含义、公共 API、最终无障碍体验和是否接受视觉差异,仍要由人判断。

本文从一个最小但完整的示例出发,连接 tokens.json、React/TypeScript 组件、Storybook、无障碍检查、视觉回归和 CI。你不需要一次重写整个前端;先挑一个按钮和一组令牌,就能建立可重复的迁移路径。

本文要解决的四件事

  • 用 primitive、semantic、component 三层令牌避免颜色值散落在组件中。
  • 让 Claude Code 只改指定目录,并在提交前留下可复核的测试结果。
  • 采用 2026 年推荐的 @storybook/addon-vitestvitest --project=storybook,同时保留必要的人工无障碍评审。
  • 把 Figma 当作评审输入,而不是一开始就进行有风险的双向自动同步。

延伸阅读可参考用 Claude Code 管理设计令牌用 Claude Code 开发 Storybook用 Claude Code 改善无障碍体验

目标架构:让 tokens.json 成为代码侧契约

这套流程以 tokens.json 为代码侧唯一可审查的事实来源。Figma 对设计工作仍然不可替代,但进入代码和 CI 的变更必须变成可比较、可回滚的契约。

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

Design Token(设计令牌)就是把设计决定以有名字的数据保存下来,例如颜色、间距、圆角、字体和组件状态。组件里直接写 #2563eb 只说明“它是蓝色”;使用 action.background.primary 之类的语义令牌,才说明“它是主要操作的背景色”。未来换品牌色时,修改令牌即可,不必在组件里搜索替换。

当前规范与工具请以官方资料为准:Design Tokens Community Group 格式Claude Code 文档Claude Code 安全指南Storybook Vitest addonStorybook 无障碍测试Storybook 视觉测试Figma REST API

Claude Code 负责什么,人负责什么

“帮我做一个设计系统”没有文件范围、验收条件和禁止事项,往往会产生难以评审的大改动。更安全的任务是:“只迁移 Button,保持公共 API,补齐 Storybook 状态,执行无障碍测试,并列出差异。”

工作领域适合交给 Claude Code必须由人决定
令牌从 CSS 找出重复颜色与间距,生成候选清单品牌含义与令牌命名
组件实现有类型的 ButtonInputAlert 基础组件公共 API 与产品语义
Storybook增加变体、状态与交互 story哪些状态对应真实业务流程
无障碍找出缺失标签、焦点问题与 axe 违规最终键盘、读屏与体验判断
CI在 Pull Request 中加入视觉与无障碍检查失败策略与例外审批流程

编辑前先把项目规则交给 Claude Code:

设计系统任务规则:
- 只允许编辑 src/components、src/styles、.storybook、tests、scripts 和 tokens.json。
- 修改品牌色前,必须列出旧令牌名与新令牌名。
- 每个新组件都要有 TypeScript props、键盘行为、Storybook stories 和无障碍说明。
- 汇报完成前,运行 npm run tokens:build、npm run test:storybook -- --run、npm run build-storybook 和 npm run test:visual。
- 如果焦点行为发生变化,附上人工检查步骤。

安全边界同样属于设计系统工程。不要把 Figma token、npm token、CI 密钥或客户私有截图粘贴到提示词中。Claude Code 权限应限制在任务需要的目录和命令;执行前检查命令;大批量 snapshot 更新必须由人确认。

最小安装:2026 年优先使用 Storybook Vitest addon

下面假设项目使用 React、TypeScript 和 utility class。使用 pnpm 或 yarn 时,请改成对应命令。

npm install class-variance-authority clsx tailwind-merge
npx storybook@latest init
npx storybook add @storybook/addon-a11y
npx storybook add @storybook/addon-vitest
npm install -D @playwright/test concurrently http-server wait-on
npx playwright install chromium

Vitest addon 需要基于 Vite 的 Storybook framework,或受支持的 Next.js Vite 集成。若旧项目暂时无法满足该条件,再参考 Storybook 官方迁移资料,把旧 @storybook/test-runner 作为兼容回退方案;不要把旧方案当作新项目的推荐配置。

package.json 中加入本地和 CI 共用的脚本。关键点是 test:storybook 使用 vitest --project=storybook

{
  "scripts": {
    "tokens:build": "node scripts/build-tokens.mjs",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test:storybook": "vitest --project=storybook",
    "test:visual": "playwright test tests/button.visual.spec.ts"
  }
}

用三层 Design Token 建立变更契约

将令牌分为 primitive、semantic、component 三层。Primitive 保存原始值;semantic 说明用途;component 表示某个组件的具体状态。这样既能保留基础色板,也能避免组件直接依赖颜色编号。

{
  "$schema": "https://www.designtokens.org/schemas/2025.10/format.json",
  "primitive": {
    "color": {
      "blue": {
        "50": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.9373, 0.9647, 1], "hex": "#eff6ff" }
        },
        "600": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.1451, 0.3882, 0.9216], "hex": "#2563eb" }
        },
        "700": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.1137, 0.3059, 0.8471], "hex": "#1d4ed8" }
        }
      },
      "gray": {
        "50": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.9765, 0.9804, 0.9843], "hex": "#f9fafb" }
        },
        "200": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.898, 0.9059, 0.9216], "hex": "#e5e7eb" }
        },
        "900": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.0667, 0.0941, 0.1529], "hex": "#111827" }
        }
      },
      "red": {
        "600": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.8627, 0.149, 0.149], "hex": "#dc2626" }
        },
        "700": {
          "$type": "color",
          "$value": { "colorSpace": "srgb", "components": [0.7255, 0.1098, 0.1098], "hex": "#b91c1c" }
        }
      },
      "white": {
        "$type": "color",
        "$value": { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" }
      }
    },
    "space": {
      "2": { "$type": "dimension", "$value": { "value": 0.5, "unit": "rem" } },
      "3": { "$type": "dimension", "$value": { "value": 0.75, "unit": "rem" } },
      "4": { "$type": "dimension", "$value": { "value": 1, "unit": "rem" } },
      "6": { "$type": "dimension", "$value": { "value": 1.5, "unit": "rem" } }
    },
    "radius": {
      "md": { "$type": "dimension", "$value": { "value": 0.375, "unit": "rem" } },
      "lg": { "$type": "dimension", "$value": { "value": 0.5, "unit": "rem" } }
    }
  },
  "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 token map,避免人工维护两份数据:

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, stack = []) {
  if (typeof value === "string" && value.startsWith("{") && value.endsWith("}")) {
    const path = value.slice(1, -1);
    if (stack.includes(path)) {
      throw new Error(`Circular token reference: ${[...stack, path].join(" -> ")}`);
    }
    return resolveValue(getToken(path), [...stack, path]);
  }
  return value;
}

function toCssValue(value) {
  if (value && typeof value === "object") {
    if (typeof value.hex === "string") return value.hex;
    if (typeof value.value === "number" && typeof value.unit === "string") {
      return `${value.value}${value.unit}`;
    }
    throw new Error(`Unsupported token value: ${JSON.stringify(value)}`);
  }
  return String(value);
}

function walk(node, pathParts = [], result = {}) {
  if (!node || typeof node !== "object") return result;

  if (node && typeof node === "object" && typeof node.$value !== "undefined") {
    result[pathParts.join("-")] = toCssValue(resolveValue(node.$value));
    return result;
  }

  for (const [key, value] of Object.entries(node)) {
    if (key.startsWith("$")) continue;
    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.`);

第一步不要让 Claude Code 重写所有 UI。先让它“找出重复的原始颜色和间距,映射成候选令牌,只输出报告,不编辑文件”。人确认命名和范围后,再迁移一个组件。

构建类型稳定的 React 组件

组件层应当朴素、可预测。下面的 Button 包含变体、尺寸、loading、disabled 和清晰的焦点样式,同时保留原生 button 属性。

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 是否稳定到可以被多个产品团队长期使用?loading 时是否真的阻止重复提交?新增变体会不会破坏已有调用?这些判断需要人结合产品场景完成。

把 Storybook 当作可执行规格

所有重要状态都应有对应 story。Storybook 中不存在的状态,很难被设计师评审、被测试捕获,也难以在团队讨论中准确引用。

import type { Meta, StoryObj } from "@storybook/react-vite";
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: "Save changes",
    variant: "primary"
  }
};

export const Danger: Story = {
  args: {
    children: "Delete",
    variant: "danger"
  }
};

export const Loading: Story = {
  args: {
    children: "Saving",
    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 保留现有 stories、只补缺少的状态,并解释任何 story ID 变化。这样视觉 snapshot 和无障碍报告仍能对应到明确的评审对象。

在 CI 中执行组件、无障碍与视觉检查

自动无障碍检查不能代替键盘和读屏器人工测试,但能提早发现不少结构性问题。对基于 Vite 的 Storybook,2026 年推荐路径是 @storybook/addon-vitest。它把 stories 转为浏览器测试;当 story 配置 parameters.a11y.test = "error" 时,无障碍 addon 报告违规会直接让组件测试失败。

CI 中执行 npm run test:storybook -- --run。与旧 @storybook/test-runner 流程不同,Vitest addon 在组件和无障碍测试时不需要另外启动一个 Storybook 服务。下方自定义 Playwright 截图仍需要构建并启动 Storybook。

视觉测试先覆盖高价值状态,不要一开始为所有 story 建 snapshot:

启用 CI 前,先在目标应用中执行一次 npx playwright test tests/button.visual.spec.ts --update-snapshots,由人工检查基准图并提交到仓库。没有基准图时,Playwright 无法比较,首次 CI 会失败。

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"
  });
});

再把令牌、组件、Storybook 与截图检查接入 GitHub Actions:

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: npx playwright install --with-deps chromium
      - run: npm run test:storybook -- --run
      - run: npm run build-storybook
      - 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:visual"

CI 失败时,把失败的 story ID、axe 违规、变更文件和视觉差异交给 Claude Code。不要把密钥或整份可能包含私密信息的日志直接贴进提示词。

Figma 集成的现实边界

Figma Variables 是令牌工作的有力输入,但初期就做自动双向同步风险很高。未审批的试验、旧组件名和私有设计备注,都可能被意外带入生产令牌。

对象适合自动化的工作应避免的工作
Figma Variables导出后与 tokens.json 比较直接覆盖生产令牌
Figma Components收集状态与 prop 候选自动决定 React API
Figma comments汇总尚未解决的问题猜测最终设计意图
Storybook links把 story URL 附在设计评审中把 Storybook 当作设计审批

先让 Claude Code 生成只读差异报告:

读取 figma-tokens-export.json 和 tokens.json。
生成一份 Markdown 报告,列出:
1. Figma 中存在但代码中不存在的令牌
2. 代码中存在但 Figma 中不存在的令牌
3. 同名语义令牌的值差异
不要编辑 tokens.json,也不要重命名令牌。标记 focus、danger 与 text color 相关的高风险差异。

目标不是为了同步而同步,而是得到一份人能安全评审的差异。团队尚未明确事实来源时,把 Figma 集成保持在 report-only 模式。

Use case 1:SaaS 管理后台逐屏迁移

SaaS 后台的按钮、表单、表格和弹窗通常有大量 loading、disabled、error 与权限状态。先让 Claude Code 统计现有 Button 用法,找出不兼容的 props,再建立兼容层并迁移一个页面。人负责确认删除、付款、权限变更等高风险操作的文案与交互。

验收标准应包括旧页面未变化、新页面全部状态进入 Storybook、键盘操作可完成,以及 token build 和 CI 都通过。不要把几十个页面塞进同一个 Pull Request。

Use case 2:白标产品的品牌切换

白标产品会为不同客户更换基础品牌色,但“主要操作”“危险操作”“正文文字”等语义应保持稳定。Claude Code 可以读取各品牌 primitive token,生成对应 CSS variables,并为 Storybook 增加主题切换器。

人需要确认颜色对比度和品牌许可,尤其是 focus 与 danger 颜色。不能因为客户给了一个品牌蓝,就自动认定它适合按钮文字或焦点环。

Use case 3:旧 CSS 的渐进式清理

旧项目常有多个近似蓝色、不同间距和重复圆角。Claude Code 可扫描 raw value,按出现次数和上下文分组,再输出“原始值 → 候选令牌 → 使用文件”的迁移表。

先替换一个组件并对比视觉 snapshot。若一次提交替换全站,颜色变化来自令牌错误还是组件例外将很难判断。每批迁移都保留回滚点。

Use case 4:营销页与咨询漏斗

咨询站点的 CTA 按钮、价格卡片和表单状态若不一致,会降低访客信任,也让 A/B 测试难以解释。Claude Code 可以统一这些组件并补齐提交中、提交失败、成功与重复点击状态。

转化策略仍由人决定。设计系统只保证实验使用相同组件和可追踪状态,不会自动证明某个文案或颜色带来更多咨询。

常见陷阱:原因与修正方法

陷阱 1:组件直接使用 primitive token

原因: blue-600 看起来方便,却把品牌外观写进组件依赖。品牌变化会变成全仓库搜索替换。

修正: 组件只使用 semantic 或 component token;primitive 只作为上层引用目标。

陷阱 2:Storybook 本地能打开就算完成

原因: 没有 CI 的组件目录可以在依赖升级后静默失效,它只是文档,不是安全网。

修正:vitest --project=storybook、Storybook build 与高价值视觉 snapshot 纳入 Pull Request 检查。

陷阱 3:视觉 snapshot 一次覆盖太多

原因: 动画、日期、外部字体和随机 ID 会制造噪音,让评审者习惯直接接受更新。

修正: 固定动态内容,从主要组件状态开始;每次 snapshot 变化都由人查看差异。

陷阱 4:axe 通过就代表无障碍完成

原因: 自动工具无法完整判断文案语义、键盘流程质量和读屏器是否容易理解。

修正: 保留键盘与读屏器人工检查,并记录测试步骤和结果。

陷阱 5:让 Claude Code 一次迁移整个系统

原因: 文件范围、视觉差异和 API 变化同时扩大,失败后无法快速定位。

修正: 按组件拆分任务,先定义证明命令,验收当前批次后再扩大范围。

合并前检查清单

  • 令牌名称表达用途,而不只表达外观
  • 组件 props 足够少且保持稳定
  • Storybook 包含 disabled、loading、error、focus、hover 状态
  • 仅用键盘也能完成操作
  • 必要处使用 ARIA,原生 HTML 已满足时不重复添加
  • 人工查看过视觉 snapshot 差异
  • Figma 差异被保存为可评审产物
  • Claude Code 只编辑了指定目录
  • 提示词、日志、story 与截图中没有密钥或客户私密数据

把清单写入项目指令,后续 Claude Code 会话就能复用同一验收标准。

下一步只做一件事

先在目标项目中建立 tokens.json,运行生成脚本,并挑现有 Button 补齐 Storybook 状态。确认 CSS variables 与 TypeScript 常量能够重复生成,npm run test:storybook -- --run、Storybook build 和视觉测试都能在 CI 重现,再迁移下一个组件。Figma 同步先保持只读报告模式。

如果团队需要一起确定设计系统的迁移范围、Storybook 引入方式、无障碍检查或 Claude Code 的权限边界,可查看培训与咨询页面

实际测试结果

2026 年 7 月 22 日,我们从本文提取 build-tokens.mjs,并针对完整的 tokens.json 示例执行。正常用例以退出码 0 完成,生成了 25 个 CSS custom properties 和 TypeScript token map,且把 {primitive.color.blue.600} 正确解析为 #2563eb。加入未知引用的反向用例以非零退出码失败,并输出 Unknown token reference。JSON 片段、代码围栏和内部链接也经过检查,Storybook 命令已与当前官方 Vitest addon 和迁移文档核对。本网站仓库本身没有安装 Storybook,因此采用这套流程前,仍需在目标应用中实际执行组件与视觉测试。

#Claude Code #design system #Design Tokens #Storybook #accessibility
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。