设计实践

写一个 Figma 插件,把文件变量一键导出为 CSS 自定义属性

写一个 Figma 插件,把文件里的变量一键导出成前端可以直接用的 CSS 自定义属性:默认模式生成 :root,深色等其他模式生成 [data-theme="dark"],变量之间的引用转换成 var(--xxx),导出结果可以复制或下载为 tokens.css。

BINBHO · 图文教程

写一个 Figma 插件,把文件里的变量一键导出成前端可以直接用的 CSS 自定义属性:默认模式生成 :root,深色等其他模式生成 [data-theme="dark"],变量之间的引用转换成 var(--xxx),导出结果可以复制或下载为 tokens.css。

先看清成品

成品是一个插件,运行后面板里显示生成好的 CSS,并有“复制”和“下载 tokens.css”两个按钮。本文代码已实际编译和测试:转换逻辑的 3 个单元测试全部通过,测试用例覆盖变量名转换、十六进制和 rgba 颜色、像素单位、别名引用和深色模式;还用模拟的 figma.variables 接口在 Node 里运行了插件主线程,确认能读取变量并跳过 CSS 不支持的类型。在真实 Figma 文件里运行没有在本文环境中操作。

做完之后你会得到什么?

成果 说明
颜色变量 不透明输出 #rrggbb,半透明输出 rgba()
数字变量 默认加 px,字重、不透明度等保持纯数字
别名 输出 var(--被引用的变量),保留设计系统的层级
多模式 默认模式 :root,其他模式 [data-theme="模式名"]
导出 复制到剪贴板或下载文件

需要准备哪些环境?

项目 要求 说明
Figma 桌面版 最新版 加载本地开发插件需要桌面版
Node.js 20 或更高 编译 TypeScript
TypeScript 5.x 类型检查
esbuild 0.28.x 把多个文件打包成插件需要的单个 code.js
@figma/plugin-typings 1.x 本文使用 1.142.0

为什么写插件而不用 REST API?官方套餐对比显示,读取变量的 REST API 只在 Enterprise 计划提供;插件 API 读取本地变量没有这个限制。另外,多个变量模式需要 Professional 及以上计划,Starter 计划每个集合只有一个模式,插件会只生成 :root。

第一步:导出的 CSS 长什么样?

用一个“基础色 + 主题”两层结构的示例文件,本文测试实际生成:

/* Primitives · Value */
:root {
  --blue-500: #3b82f6;
  --gray-900: #111827;
  --white: #ffffff;
  --space-4: 16px;
  --font-weight-bold: 700;
}

/* Theme · Light */
:root {
  --color-bg: var(--white);
  --color-overlay: rgba(0, 0, 0, 0.5);
}

/* Theme · Dark */
[data-theme="dark"] {
  --color-bg: var(--gray-900);
  --color-overlay: rgba(0, 0, 0, 0.7);
}

前端在 <html data-theme="dark"> 上切换属性,就能切换主题:

<button onclick="document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? '' : 'dark'">切换主题</button>

第二步:项目结构是什么样的?

variables-to-css/
├── manifest.json
├── ui.html
├── src/
│   ├── code.ts          # 读取 Figma 变量
│   ├── tokens.ts        # 转换为 CSS(纯函数)
│   └── tokens.test.ts
├── tsconfig.json
├── tsconfig.test.json
└── package.json

package.json、两份 tsconfig 和 manifest.json 的写法与本系列的“批量重命名图层插件”相同,只是名称不同:

{
  "name": "导出变量为 CSS",
  "id": "000000000000000000",
  "api": "1.0.0",
  "main": "dist/code.js",
  "ui": "ui.html",
  "editorType": [
    "figma"
  ],
  "documentAccess": "dynamic-page",
  "networkAccess": {
    "allowedDomains": [
      "none"
    ]
  }
}

1. 让 AI 写初稿(可选)

写一个 Figma 插件,把本地变量导出为 CSS 自定义属性:
- code.ts 用 figma.variables.getLocalVariableCollectionsAsync 和 getVariableByIdAsync 读取变量,整理成普通对象发给纯函数
- tokens.ts 输出:默认模式用 :root,其他模式用 [data-theme="模式名小写"]
- 颜色不透明时用 #rrggbb,否则 rgba;数字加 px,名字含 opacity/weight/line-height 的不加
- VARIABLE_ALIAS 转成 var(--目标变量名)
- 变量名 color/bg/Primary 转成 --color-bg-primary

第三步:核心代码怎么写?

1. src/tokens.ts

// 纯函数:把 Figma 变量数据转换成 CSS 自定义属性
export type Value = number | string | boolean | { r: number; g: number; b: number; a?: number } | { type: "VARIABLE_ALIAS"; id: string };

export interface Var {
  id: string;
  name: string;                       // 例如 "color/bg/primary"
  resolvedType: "COLOR" | "FLOAT" | "STRING" | "BOOLEAN";
  valuesByMode: Record<string, Value>;
  scopes?: string[];
}

export interface Collection {
  name: string;
  defaultModeId: string;
  modes: { modeId: string; name: string }[];
  variables: Var[];
}

export const cssName = (name: string) =>
  "--" + name.trim().toLowerCase().replace(/[\/\s.]+/g, "-").replace(/[^a-z0-9_-]/g, "");

const hex2 = (x: number) => Math.round(x * 255).toString(16).padStart(2, "0");

export function color(c: { r: number; g: number; b: number; a?: number }): string {
  const a = c.a ?? 1;
  return a < 1 ? `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${+a.toFixed(3)})` : `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`;
}

// 数字默认加 px;名字里带 opacity、weight、line-height、z 的保持纯数字
const unitless = /(opacity|weight|line-height|lineheight|z-index|zindex|ratio)/i;

export function formatValue(v: Var, value: Value, byId: Map<string, Var>): string {
  if (typeof value === "object" && value !== null && "type" in value && value.type === "VARIABLE_ALIAS") {
    const target = byId.get(value.id);
    return target ? `var(${cssName(target.name)})` : "/* 引用了其他文件的变量 */ initial";
  }
  switch (v.resolvedType) {
    case "COLOR": return color(value as { r: number; g: number; b: number; a?: number });
    case "FLOAT": return unitless.test(v.name) ? String(value) : `${value}px`;
    case "BOOLEAN": return value ? "1" : "0";
    default: return JSON.stringify(String(value));
  }
}

export function toCss(collections: Collection[]): string {
  const byId = new Map<string, Var>();
  collections.forEach((c) => c.variables.forEach((v) => byId.set(v.id, v)));
  const blocks: string[] = [];
  for (const c of collections) {
    for (const mode of c.modes) {
      const selector = mode.modeId === c.defaultModeId ? ":root" : `[data-theme="${mode.name.toLowerCase().replace(/\s+/g, "-")}"]`;
      const lines = c.variables
        .filter((v) => mode.modeId in v.valuesByMode)
        .map((v) => `  ${cssName(v.name)}: ${formatValue(v, v.valuesByMode[mode.modeId], byId)};`);
      if (lines.length) blocks.push(`/* ${c.name} · ${mode.name} */\n${selector} {\n${lines.join("\n")}\n}`);
    }
  }
  return blocks.join("\n\n") + "\n";
}
规则 示例
/、空格、点转成 - color/bg/Primary Hover → --color-bg-primary-hover
颜色分量是 0 到 1 的小数 乘以 255 后转十六进制
别名 { type: "VARIABLE_ALIAS", id } → var(--xxx)
引用其他文件的库变量 本文件找不到目标,输出注释提示

2. src/code.ts

import { Collection, Var, toCss } from "./tokens";

const SUPPORTED = ["COLOR", "FLOAT", "STRING", "BOOLEAN"];

figma.showUI(__html__, { width: 480, height: 520, themeColors: true });

async function readCollections(): Promise<Collection[]> {
  const cols = await figma.variables.getLocalVariableCollectionsAsync();
  const result: Collection[] = [];
  for (const c of cols) {
    const vars = [];
    for (const id of c.variableIds) {
      const v = await figma.variables.getVariableByIdAsync(id);
      // 新版 Figma 还有 EASING 等类型,CSS 里没有直接对应,先跳过
      if (!v || !SUPPORTED.includes(v.resolvedType)) continue;
      vars.push({ id: v.id, name: v.name, resolvedType: v.resolvedType as Var["resolvedType"], valuesByMode: v.valuesByMode as Var["valuesByMode"] });
    }
    result.push({ name: c.name, defaultModeId: c.defaultModeId, modes: c.modes, variables: vars });
  }
  return result;
}

readCollections().then((cols) => {
  const count = cols.reduce((n, c) => n + c.variables.length, 0);
  figma.ui.postMessage({ type: "css", css: toCss(cols), count, collections: cols.length });
});

figma.ui.onmessage = (m: { type: string }) => {
  if (m.type === "copied") figma.notify("已复制到剪贴板");
  if (m.type === "close") figma.closePlugin();
};

读取接口都是异步的(带 Async 后缀),这是 documentAccess: "dynamic-page" 模式的要求。

3. ui.html

<style>
  body { font: 12px system-ui, sans-serif; margin: 12px; color: var(--figma-color-text); background: var(--figma-color-bg); }
  textarea { width: 100%; height: 400px; box-sizing: border-box; font: 12px ui-monospace, monospace; }
  button { margin-top: 8px; padding: 8px 12px; }
</style>
<div id="info">读取变量中…</div>
<textarea id="out" readonly></textarea>
<button id="copy">复制</button>
<button id="download">下载 tokens.css</button>
<script>
  const out = document.getElementById("out");
  onmessage = (e) => {
    const m = e.data.pluginMessage;
    if (m.type !== "css") return;
    document.getElementById("info").textContent = `共 ${m.collections} 个集合、${m.count} 个变量`;
    out.value = m.css;
  };
  document.getElementById("copy").onclick = () => {
    out.select();
    document.execCommand("copy"); // 插件 iframe 里 navigator.clipboard 不一定可用
    parent.postMessage({ pluginMessage: { type: "copied" } }, "*");
  };
  document.getElementById("download").onclick = () => {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(new Blob([out.value], { type: "text/css" }));
    a.download = "tokens.css";
    a.click();
  };
</script>

第四步:怎样编译、测试和加载?

1. 编译

npm install
npm run build

2. 在 Figma 里加载

打开 Figma 桌面版和任意设计文件,菜单 插件 → 开发 → 从清单导入插件,选择项目里的 manifest.json。之后在 插件 → 开发 里就能运行。

修改代码后重新执行 npm run build(或保持 npm run watch 运行),再在 Figma 里重新运行插件即可。

注意:manifest.json 里的 id 是占位值。本地开发可以直接用;要发布到 Figma 社区时,在发布流程里获取正式 ID 并替换。

3. 运行单元测试

npm test

本文实际输出(节选):

ok 1 - 变量名转 CSS 名
ok 2 - 颜色:不透明用十六进制,半透明用 rgba
ok 3 - 生成 :root 和暗色主题,别名转为 var()
# pass 3
# fail 0

4. 在 Figma 里检查结果

操作 期望
在有变量的文件里运行插件 显示“共 N 个集合、M 个变量”
查看文本框 每个集合、每个模式一段 CSS
点“下载 tokens.css” 浏览器下载文件
把文件引入网页 var(--color-bg) 生效,切换 data-theme 主题改变

可以直接用本系列“UI Kit 变量”教程里做好的文件测试。

出问题时该怎么调?

1. 编译报 Type '"EASING"' is not assignable

本文编译时遇到:

Type 'VariableResolvedDataType' is not assignable to type '"COLOR" | "FLOAT" | "STRING" | "BOOLEAN"'.
  Type '"EASING"' is not assignable to type '"COLOR" | "FLOAT" | "STRING" | "BOOLEAN"'.

新版插件类型定义里,变量多了 EASING 等类型,而 CSS 里没有直接对应的写法。code.ts 里用 SUPPORTED 列表过滤,只导出四种基础类型。需要导出动效曲线时,可以自行把它转换成 cubic-bezier()。

2. 输出里有“引用了其他文件的变量”

变量引用的是团队库里的变量,当前文件没有它的定义。可以在库文件里也运行一次插件,把两份 CSS 一起引入。

3. 所有模式都输出成了 :root

文件只有一个模式(Starter 计划,或者还没添加模式),这是正常结果。

4. 数字单位不对

本文规则是数字默认加 px。行高等需要纯数字的变量,名字里包含 line-height 就不会加单位;其他特殊情况修改 unitless 正则。

常见问题怎么处理?

现象 原因 解决方法
变量数是 0 文件里没有本地变量 先在变量面板创建,或在库文件里运行
两个变量 CSS 名相同 只有大小写或符号不同 在 Figma 里改名区分
复制按钮没反应 剪贴板权限 手动全选文本框复制,或用下载按钮
深色模式选择器名字不对 选择器来自模式名 把模式改名为英文,如 Dark
想输出 JSON 给其他工具 本插件只输出 CSS 在 tokens.ts 里加一个输出函数
想在 CI 里自动导出 插件需要人工运行 REST API 需要 Enterprise 计划

项目资源

用途 地址
变量插件 API https://developers.figma.com/docs/plugins/api/figma-variables/
Variable 对象 https://developers.figma.com/docs/plugins/api/Variable/
VariableCollection 对象 https://developers.figma.com/docs/plugins/api/VariableCollection/
插件开发入门 https://developers.figma.com/docs/plugins/
变量模式 https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables
套餐功能对比 https://help.figma.com/hc/en-us/articles/360040328273-Compare-Figma-plans-and-features
CSS 自定义属性 https://developer.mozilla.org/zh-CN/docs/Web/CSS/Using_CSS_custom_properties

建议先跑 npm test 看懂转换规则,再在 Figma 里建三五个变量和一个深色模式,运行插件看输出。

确认格式符合你项目的需要后,再在真实的设计系统文件上导出,并把 tokens.css 交给前端试用。