Figmaプラグイン開発超入門〜バリアブルをノード型UIで編集してみたい〜

こんにちは!エンジニアの藤井(touyou)です! この記事はGoodpatch Advent Calendar 2024の12日目の記事になります。

みなさん、Figma、使ってますか?

自分は現在、社内のデザインシステムチームにエンジニアとして参画して、デザインシステムの成長にエンジニア視点で貢献するということをサブプロジェクト的に取り組んでおり、デザイナーと一緒に有効性を検証するテストに参加したり、インタビューの分析をしたり、実際のドキュメント執筆に携わったりなど、職種の域を超えて活動しています。
その中でも直近力を入れて取り組んでいるのが「Figmaプラグイン開発」です。
実際のサブプロジェクトで制作しているものはFigmaのUIキットをより使いやすくするためのプラグインであり、特化型のプラグインとして開発しているのですが、製作していく中でもう少し汎用性を持たせて、なおかつノード型UIを使えば面白いものになるのではないか、と個人的に思うところがあり今回チャレンジしてみることにしました。

今回の記事はその取り組みを踏まえてリッチなUIを持つFigmaプラグイン制作を始める手順をできる限り丁寧に解説し、最後にチャレンジしてみたノード型UIのプラグインについて紹介できればと思います。

プラグイン開発してみたい、でも公式のリファレンスだけだとリッチなものが作りにくい...と感じている方にもきっと役立つ記事になるかと思うのでぜひ開発のおともに最後までお付き合いください!

Part 1. リッチUIプラグインのための環境構築

Figmaプラグイン開発が、実は意外とすぐに始められることをみなさんはご存知でしょうか?
ヘルプページに書かれてあるのですが、とりあえず環境構築をしてみるだけであればFigmaのデスクトップアプリからマウス操作だけで最初のセットアップをすることができます。
ですがこれ、生のHTMLやJS, CSSを触る必要がありリッチなUIを作ろうと思うと現代のフロントエンド界隈の開発環境に比べて少し骨が折れるところがあります。そこで今回紹介するのはFigmaプラグインをReact.jsのアプリとして作成する方法です。さっそくみていきましょう。

Step1. GitHubテンプレートから作成する

まず最初にやることがGitHubテンプレートからのリポジトリ作成です。
もちろん自分でやろうと思えばできると思うのですが、いかんせんややこしいところにあふれているのが環境構築というものです。ですのでここは先人の遺産をしっかりと活かしていきましょう。

今回使うテンプレートは以下のViteで構築するものにしました。

github.com

最近勢いのあるフロントエンドツールでもありますし、いくらリッチにするとはいえFigmaプラグインは基本SPAになると思うので、さまざまな観点でViteはちょうどいい選択肢になるのではないでしょうか。

このテンプレートから自分のリポジトリを作り、ローカル環境にクローンすることで第一段階完了です。

Step2. Node.jsのバージョンを合わせる

続いてはNode.jsに関してです。わざわざステップに取り上げるほどではない点ではありますが、しっかりとバージョン管理ツールでバージョンを合わせておくといいでしょう。
自分はローカル環境でasdf、そして直近はpnpmを使うようにしているため以下のようにセットアップしました。

$ git clone ...
$ cd ...
# バージョン指定
$ asdf local nodejs 22.11.0
# テンプレに元々入っているものをインストールする
$ pnpm install

ここまでで基本的なセットアップは完了です。

Step3. UI実装を省エネする

最後にもう少しリッチなUIを作りやすいように改造していきましょう。
今回は必要に応じてコンポーネントを使うことができ、カスタマイズが容易なものがあれば目的を達成できそうです。そこで今回はshadcn/uiを導入していきます。
基本的には公式のドキュメント通りなのですが、テンプレートを改造するということでいくつか手順が必要だったので一通り紹介していきます。

まずはtailwindcssを以下のように導入します。

$ pnpm install -D tailwindcss autoprefixer
$ pnpm dlx tailwindcss init -p

shadcn/uiのドキュメントにはpostcssもこの時に入れることになっていますが、今回は元々テンプレートに入っているのでそれを活用するかたちで進めます。
続いてファイルを書き換えていきます。

まずスタイルファイルですが、src/ui/styles配下にscssファイルが置かれているのでこれらを削除してしまいましょう。そして以下のようなmain.cssファイルのみにします。

@tailwind base;
@tailwind components;
@tailwind utilities;

そしていくつかのファイルを次のように編集していきましょう。

tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./src/ui/index.html", "./src/ui/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Node",
    "useDefineForClassFields": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": false,
    "noEmit": true,
    "jsx": "react-jsx",
    "typeRoots": ["./node_modules/@figma"],
    "paths": {
      "@common/*": ["./src/common/*"],
      "@ui/*": ["./src/ui/*"],
      "@plugin/*": ["./src/plugin/*"],
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "src/**/*.d.ts",
    "src/**/*.ts",
    "src/**/*.tsx"
  ]
}

vite.config.ui.ts

import { defineConfig } from "vite";
import path from "node:path";
import { viteSingleFile } from "vite-plugin-singlefile";
import react from "@vitejs/plugin-react";
import richSvg from "vite-plugin-react-rich-svg";
import postcssUrl from "postcss-url";
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
  plugins: [react(), richSvg(), viteSingleFile()],
  root: path.resolve("src/ui"),
  build: {
    minify: mode === "production",
    cssMinify: mode === "production",
    sourcemap: mode !== "production" ? "inline" : false,
    emptyOutDir: false,
    outDir: path.resolve("dist"),
    rollupOptions: {
      input: path.resolve("src/ui/index.html"),
    },
  },
  css: {
    postcss: {
      plugins: [tailwindcss, autoprefixer, postcssUrl({ url: "inline" })],
    },
  },
  resolve: {
    alias: {
      "@common": path.resolve("src/common"),
      "@ui": path.resolve("src/ui"),
      "@": path.resolve("src"),
    },
  },
}));

基本的には公式ドキュメント通りなのですが、ポイントとして今回のテンプレートがcommonフォルダ・pluginフォルダ・uiフォルダの三つに分かれているので基本全ての設定がuiフォルダに向くように調整しています。
またpostcss自体は元々テンプレートで指定されていますが、そのままだとtailwindcssが認識されないためvite.config.ui.tsでのプラグイン指定を忘れないようにしましょう。

ここまでできたらshadcn/ui自体のセットアップをします。まず以下のコマンドで初期化してください。

$ pnpm dlx shadcn@latest init

するとコンポーネント設定のファイルができるためそちらもuiフォルダが起点になるよう修正しておきます。

components.json

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.js",
    "css": "src/ui/styles/main.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@ui/components",
    "utils": "@ui/lib/utils",
    "ui": "@ui/components/ui",
    "lib": "@ui/lib",
    "hooks": "@ui/hooks"
  },
  "iconLibrary": "lucide"
}

こうすることで例えば以下のようにコンポーネントを追加できるようになりました。

$ pnpm dlx shadcn@latest add button

なおこの時点で消したscssを元にしたコードや、テンプレートのコンポーネントが残っているかと思うのでそちらは適宜消しておきましょう。

Part 2. 実際の開発例を紐解く

1. Figmaプラグイン開発の基礎固め

さてここからが本番です。テンプレートを使って具体的にどのようにFigmaプラグインを作っていけば良いのでしょうか?

まずは今回作ったプラグインを見てみましょう。最終的には以下のようなプラグインを作成しています。

具体的なUIの実装方法に関しては本筋からは外れるので軽い紹介に留めておきますが、xyflowのうちのReact Flowというものを活用しています。

reactflow.dev

ノードのカスタマイズにReactコンポーネントをそのまま使えるためshadcn/uiもしっかり活用できて便利です。もし似たようなことを考えている人はぜひ触ってみてください。

話を戻してFigmaプラグインの話です。今回利用しているテンプレートはmonorepo-networkerというものを活用してUIとFigmaロジックの関心の分離をしてくれています。
Part 1でも一部紹介しましたが、改めてsrcフォルダ配下の構成を見ると具体的には次のような形になっています。

src
├── common ... FigmaのロジックとプラグインのUI双方で使うファイル
│   ├── network ... monorepo-networker関連のファイル
│   │   └── messages ... FigmaのロジックとプラグインのUIとの間のデータのやり取りを定義したファイル
├── plugin ... Figmaプラグイン自体の設定
└── ui ... UI部分・UIロジックなどのファイル

ややこしい部分もあるのですが、もう少し制約的な面で説明するとuiフォルダ内はそれ単体でReactアプリケーションとして動作する状態になっている代わりに、monorepo-networkerを通してでしかFigmaのAPIにさわれないように設定されています。その分変なAPIに間違って触れてしまうということもなく安心です。
一方pluginはReactに左右されない作りになっているためシンプルなコードを保てます。ウィンドウサイズやタイトルを変えたい場合はこちらで編集しましょう。

そしてこの構成で編集の本丸となるのはcommon配下です。一個ずつ見ていきましょう。

まずUI側からFigmaのAPIにさわれないという制約の関係もあり、UIに対してFigmaの型をそのまま変換することはできません。そこで自分はUI側で使う型定義をcommonフォルダ内にして、そちらにマッピングするような形でデータをやり取りすることにしました。
今回自分はバリアブルを操作するプラグインを制作したため、例えば以下のような型を定義しています。

/// バリアブルのエンティティ
export type VariableEntity = {
  /// ID
  id: string;
  /// 名前
  name: string;
  /// データ型
  type: VariableDataType;
  /// コレクションのID
  collectionId: string;
  /// 値
  valuesByMode: { [modeId: string]: VariableValueType };
}

/// コレクションのエンティティ
export type CollectionEntity = {
  /// ID
  id: string;
  /// 名前
  name: string;
  /// モードの一覧
  modes: ModeEntity[];
  /// バリアブルのIDの一覧
  variableIds: string[];
}

これらの構造を決めるに当たってはFigmaのAPIとして使われている型を確認するのが良いかと思います。公式ドキュメントに記載されていたり、VS Codeなどで定義を確認しにいくことも可能なので好きな方法で必要なものを参照し、型を決めていきましょう。

つづいて型を定義してやり取りする準備ができたら、monorepo-networkerのMessageクラスを定義していきます。Messageクラスの基本構造は次のようなものになります。

// 引数となる型の定義
type Payload = {
  // ...
};
// 返り値となる型の定義
type Response = Promise<{
  // ...
}>;

// Networker.MessageTypeを拡張する形で定義
export class FetchVariablesMessage extends Networker.MessageType<
  Payload,
  Response
> {
  // どちら側で処理するかを決める。Figmaプラグイン開発ではほとんどがPLUGIN側になるはず
  public receivingSide(): Networker.Side {
    return NetworkSide.PLUGIN;
  }

  // 実際の処理を書く
  public handle(payload: Payload, from: Networker.Side): Response {
    // プラグインの呼び出し元がFigmaなのかFigJamなのかの判断
    if (figma.editorType === "figma") {
      const result = (async () => {
        // ...
      })();
      return result;
    }
    return Promise.resolve({});
  }
}

FigJamでも動作するプラグインである場合は条件分岐を適宜変えてください。
Figmaプラグインで使うにあたっての注意点は、現在のFigmaのプラグインAPIのほとんどが非同期処理になっている*1ため、どうにかしてhandleメソッド内で非同期処理を呼び出す必要があるということです。handle自体はasyncにすることができないため、返り値をPromise型にして無名のasync関数の返り値をそのまま返すという形で対応しています。

メッセージの定義ができたら、つづいてそれを呼び出せるように登録していきます。登録はsrc/common/network/messages.tsに記述します。
すでにいくつかのメッセージが登録されているのにそれにならって書きましょう。GitHub Copilotなどを動かしていれば9割ぐらいの確率で勝手に予測してくれます。

export namespace NetworkMessages {
  export const registry = new Networker.MessageTypeRegistry();

  // ... テンプレートに用意されているメッセージ

  export const FETCH_VARIABLES = registry.register(
    new FetchVariablesMessage("fetch-variables")
  );
}

イニシャライザに渡している"fetch-variables"という部分に関してはおそらくどんな文字列でもいいので変数名の小文字表記にしておくのが無難なのかなと思っています。

これで準備がすべて完了しました。ui側から呼び出すには以下のように書けばオッケーです。

const result = await NetworkMessages.FETCH_VARIABLES.request({});
await NetworkMessages.SET_VARIABLES_ALIAS.send(payload);

なおrequestsendの差分としてはレスポンスを受け取るかどうかだと思います。自分の場合は上記のようにバリアブルを取得するところではrequest、バリアブルをセットするところではsendを使いました。

2. ノード型バリアブル編集プラグインを実際に作ってみる

さてここまでで環境構築、テンプレートの構成、Figmaプラグインの実際の処理を書くおまじないの部分を紹介してきました。
これでみなさんリッチなUIのFigmaプラグインを作ることができるようになったと思いますが、せっかくなので最後に今回自分が作ったものについてより具体を解説していこうと思います。

解説するにあたって前提としてFigmaのバリアブルの構造についておさらいしておきましょう。
Figmaでローカルバリアブルの設定を開くと以下のような画面が出てくると思います。

ここで0: Primitivesとなっている部分がコレクションです。このコレクションには現在パネルに出ているバリアブル全てが属しています。
一方サイドバーで見られるような階層構造はFigmaの仕組み上概念が定義されていません。あくまでFigmaのUI上での擬似的な表現でしかなく、名前をスラッシュ区切りでつけることで、名前の一部が同じものがグルーピングされて扱われます。
普段Figmaを使っているだけでは理解しにくいと思うので、実際にこれらバリアブルを扱うFigmaのAPIを見てみましょう。取得をするためのメソッドは以下の二つです。

const collections = await figma.variables.getLocalVariableCollectionsAsync();
const variables = await figma.variables.getLocalVariablesAsync();

複数取得という意味で存在しているAPIは実質これらのみで、引数指定できるのはバリアブル取得時のデータ型指定のみとなっています。
それぞれはVariableCollectionVariableの配列を返します。
ただリンク先のリファレンスを確認してもらえればわかりますが、VariableCollectionにはあくまでVariableのidの配列しか存在しません。代わりではないですがmodeという概念の一覧は名前とidを両方持っています。これはバリアブルの設定パネルでいう縦列の部分です。Light/Darkモードの定義であったり、iOS/Androidの切り替えに使っている人もいたりするものです。
一方でVariableには名前や実際の値が入っています。ただし値はmodeのidをキーにした辞書型で保持されており、そのため値とモードの対応を取るにはコレクション・バリアブルの両方を取得する必要があります。また設定する際もmodeのidが必要です。そして一番のポイントはコレクションのidと名前、データ型以外にグルーピングに使われるような情報がないということです。そのため先ほど述べた通り階層構造というのは擬似的なもの、というとらえ方が開発をする上では必要になってきます。

以上ふまえて自分はバリアブルの取得を次のように行なっています。

  public handle(payload: Payload, from: Networker.Side): Response {
    if (figma.editorType === "figma") {
      const result = (async () => {
        // まずコレクションを取得し置換
        const collections =
          await figma.variables.getLocalVariableCollectionsAsync();
        const collectionEntities: CollectionEntity[] = collections.map((collection) => {
          return {
            id: collection.id,
            name: collection.name,
            variableIds: collection.variableIds,
            modes: collection.modes.map((mode) => {
              return {
                id: mode.modeId,
                name: mode.name,
              };
            }),
          };
        });
        // 同様にバリアブルも全てとってくる
        const variables = await figma.variables.getLocalVariablesAsync();
        const variableEntities: VariableEntity[] = variables.map((variable) => {
          const valuesByMode = variable.valuesByMode;
          // 辞書型にあるもの全てをマッピングする形にしています
          const values = Object.keys(valuesByMode).reduce(
            (acc, modeId) => {
              const value = valuesByMode[modeId];
              if (value)
              acc[modeId] = mapToEntity(value);
              return acc;
            },
            {} as { [modeId: string]: VariableValueType }
          );
          return {
            id: variable.id,
            name: variable.name,
            type: mapToDataType(variable.resolvedType),
            collectionId: variable.variableCollectionId,
            valuesByMode: values,
          };
        });

        return {
          // ここで名前順にソートしておく
          variables: variableEntities.sort((a, b) => a.name.localeCompare(b.name)),
          collections: collectionEntities,
        };
      })();
      return result;
    }
    return Promise.resolve({});
  }

最後バリアブルをソートしているのは、擬似的なグルーピングを再現しやすくするためです。特にバリアブルの一番末尾の名前が数字などになっていれば特にUI側で何かしなくてもその数字順でデータを取り出せるようになります。

続いて今回のプラグインでは擬似的な階層構造をまとめて一つのノードにしてみるということがしたかったのでUI側で続きの加工をしていきます。
具体的にはColorGroupという型をUI側で定義してそこにバリアブルをまとめていきました。グループの名前と個別になった時の名前は次のように取れるので、それを元にグルーピングしています。

/// バリアブルのグループ名を取得する
export const variableGroupName = (variable: VariableEntity): string => {
  const splittedName = variable.name.split("/");
  return splittedName.slice(0, -1).join("/");
}

/// バリアブルの一番深い名前を取得する
export const variableChildName = (variable: VariableEntity): string => {
  const splittedName = variable.name.split("/");
  return splittedName[splittedName.length - 1];
}

このときにノードを繋げる情報はエイリアスを採用します。エイリアスとはバリアブルを設定する際に別のバリアブルの情報を引用する機能です。
例えば数字でトークン管理されているデザインシステムなどでは次のようにエイリアスが設定されています。

つまりこの画面であれば、PrimaryのカラートークンのエイリアスにBlueのカラートークンが設定されているということですね。
なぜノード型なのか、をここまであまり書いてきませんでしたが結論このエイリアスを設定し、視覚化するのにノード型がもってこいなのではと考えたため今回の試みにいたっています。

Figmaの操作として残るはこのエイリアスをどう設定するかですね。これに関しては該当のコレクションの情報をあらかじめ持っておき、modeのidを繋ぐ操作の際に活用できるようにしています。そして繋げたい情報を組み合わせて以下のようにAPIに受け渡しています。

      (async () => {
        // modeIdはコレクションごとに違う可能性があるため、書き換えたいバリアブルのコレクションを探す
        const collection = collections.find(
          (c) => c.id === targetGroup.collectionId
        );
        const payload = {
          entities: sourceGroup.colors.map((sourceColor, i) => {
            return {
              id: targetGroup.colors[i].id,
              // 今回は先頭のモード固定にしている
              modeId: collection!.modes[0].id,
              sourceId: sourceColor.id,
            };
          }),
        };
        await NetworkMessages.SET_VARIABLE_ALIAS.send(payload);
        // データの再取得をしているが、ローカルをそのまま書き換えられることが理想
        await fetchVariables();
      })();

今回は同数の色をまとめてマッピングする形式を取っているためこのような書き方になっていますが、単純なマッピングではなかったり、モードが複数あったりする場合はもう少し工夫の必要がありそうです。
そしてメッセージの定義の方は以下のようにもらった情報を使ってバリアブルを探し、情報を書き換えるという単純な操作を行うようにしました。

export class SetVariableAliasMessage extends Networker.MessageType<Payload, void> {
  public receivingSide(): Networker.Side {
    return NetworkSide.PLUGIN;
  }

  public handle(payload: Payload, from: Networker.Side): void {
    if (figma.editorType === "figma") {
      (async () => {
        // 取得の際もVariable型のインスタンスが必要なのでまず取得をする
        const variables = await figma.variables.getLocalVariablesAsync();
        payload.entities.forEach((entity) => {
          const variable = variables.find((v) => v.id === entity.id);
          variable?.setValueForMode(
            entity.modeId,
            {
              id: entity.sourceId,
              type: "VARIABLE_ALIAS",
            },
          );
        });
      })();
    }
  }
}

以上がFigmaのバリアブルを編集するために必要なことになります。

最後に今回作ったカスタムノードのコードを紹介して終わろうと思います。その他のReact Flowの使い方に関してはかなり簡単に扱えると思うので公式ドキュメントを参考にしてみてください。

// ここで引数として受け取るものを定義しておく
export type ColorNode = Node<
  {
    group: ColorGroup;
  },
  "color"
>;

export function ColorNode(props: NodeProps<ColorNode>) {
  const { group } = props.data;

  return (
    <>
      // ハンドルはスタイル書き換えが可能。デフォルトだと操作しにくいのでサイズを大きくできると良さそうです
      <Handle
        type="source"
        position={Position.Right}
        className="w-3 h-3 bg-white border-[#999] border-2 hover:border-[#666]"
      />
      // ここはshadcn/uiを利用しています。他のUIライブラリなどもそのまま扱えそうです
      <Card>
        <CardHeader>
          <CardTitle>{group.name}</CardTitle>
          <CardDescription>
            色数:{group.colors.length}, {group.isAlias ? "エイリアス" : "値"}
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-5 gap-1">
            {group.colors.map((color) => (
              <div
                key={color.id}
                className="w-6 h-6 rounded-md border-2"
                style={{
                  backgroundColor: `rgb(${color.r * 255} ${color.g * 255} ${
                    color.b * 255
                  } / ${color.a})`,
                }}
              />
            ))}
          </div>
        </CardContent>
      </Card>
      {group.isAlias && (
        <Handle
          type="target"
          position={Position.Left}
          className="w-3 h-3 bg-white border-[#999] border-2 hover:border-[#666]"
        />
      )}
    </>
  );
}

こちらを設定すると動画でも紹介した下の画像にあるようなノードを実装することができます。

まとめ

気づいたら超入門というタイトルにふさわしい長編になってしまいましたが皆さんいかがだったでしょうか?
皆さんの中でFigmaプラグイン開発のイメージがより身近なものになってくれていたらなによりです。また今回は最小限の紹介になってしまいましたが、React FlowはDifyやSupabaseなど近年流行りのプロダクトにも採用されているものなので、ノード型UIに興味ある方はぜひチェックしてみてください。

今回はカラーのみ、かつ色数が同じグループ同士の操作にとどめてノード型UIのプラグインを作成しましたが、この構想にはまだ課題が残っています。

  • 色数が異なるものの間でのエイリアスの貼り直しは単純にはできない
  • 色だけでもこれだけボード空間を占領してしまい、一覧性には欠けてしまう
  • エイリアスの貼り直しにはエッジの貼り直しのみでいいが、値をいじろうとするとこれだけでは操作できない

そのため今後は以下のようなアップデートが必要になってくるかと思います。

  • プラグイン内のモードの切り替えで表示するバリアブルを種類ごとに切り替える
  • エイリアスでないものはノード内で編集できるようにする
  • グループごとにエイリアスを貼り直せる利便性を担保しながら、バラバラのバリアブルの編集にも強い形式を考える
  • バリアブルに複数のモードがあっても対応できるようにする

まだまだやることも多く、今回はFigmaプラグイン公開までは間に合いませんでしたが、引き続き時間を見つけて開発を続けていこうと思います。ぜひ公開した際は一度使ってみてください!


Goodpatchではデザイン好きなエンジニアの仲間を募集しています。 少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!

*1:非同期ではないメソッドも存在し開発上は使えるのですが、公開する際に弾かれてしまうためずっとローカルで動かし続けるプラグインでない限り避ける必要があります。