写一个 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 计划 |
项目资源
建议先跑 npm test 看懂转换规则,再在 Figma 里建三五个变量和一个深色模式,运行插件看输出。
确认格式符合你项目的需要后,再在真实的设计系统文件上导出,并把 tokens.css 交给前端试用。