Figmaのデザインをデザイントークンを使って実装した話

この記事は、よりそうアドベントカレンダーの12月15日の記事となります。

# はじめに
現在、Nuxt2 → 3 への移行作業を行なっており、新デザインでのサイトのFE実装を行っています。
新デザインではカラースキーマやレイアウトルール、タイポグラフィーなどのデザイン原則が定められています。

それらを担保するために、今回デザイントークンを導入してFE実装をおこなったので、今回はその内容について書いてみようと思います。

# デザイントークンとは

> デザイントークンは、デザインシステムの一部であり、UI（ユーザーインターフェース）のデザインにおいて一貫性とスケーラビリティを実現するために使用される、再利用可能な値や属性の集合です。具体的には、色、フォント、間隔、影の強さなどのデザイン要素を標準化し、これらの値を変数として定義します。これにより、デザイナーや開発者は、プロジェクト全体で一貫した外観と感触を容易に実現できるようになります。
>
> たとえば、特定のブランドの「プライマリカラー」をデザイントークンとして定義すると、ウェブサイトやアプリケーションの様々な部分でこの色を再利用できます。デザイントークンの使用により、デザインの変更が必要な場合に、一か所の変更でプロジェクト全体にわたって一貫性を保つことが容易になります。これは、特に大規模なプロジェクトや、多くのプラットフォームやデバイスにわたって展開されるプロジェクトにおいて非常に有効です。
>
> 【Chat GPTからの引用】

今回実装したサイトでは、figma上でデザイントークンが定められており、その情報をフロントエンドの実装に落とし込む作業をしました。

# figmaからのデザイントークンの書き出し
今回のデザインでは、[figmaのバリアブルという機能](https://help.figma.com/hc/ja/sections/14506605769879-%E3%83%90%E3%83%AA%E3%82%A2%E3%83%96%E3%83%AB)を使用して、色のカラーコードや、アイコンのサイズなどの値が定義されています。

今回はその値を[DesignTokens](https://www.figma.com/community/plugin/888356646278934516)というプラグインを使用して、json形式で出力しました。

![スクリーンショット 2023-12-14 19.59.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134725/c332131b-d499-2b36-b979-32f8b6427cbf.png)

今回はバリアブルとして定義されているものに加え、フォントスタイルをjson形式で書き出しました。

```json:書き出したjsonファイルの一部
{
  // フォントスタイルに関する情報
  "font": {  
    "text": {
      "text": {
        "text-xl": {
          "type": "custom-fontStyle",
          "value": {
            "fontSize": 18,
            "textDecoration": "none",
            "fontFamily": "Hiragino Kaku Gothic Pro",
            "fontWeight": 400,
            "fontStyle": "normal",
            "fontStretch": "normal",
            "letterSpacing": 0,
            "lineHeight": 30.6,
            "paragraphIndent": 0,
            "paragraphSpacing": 0,
            "textCase": "none"
          }
        },
        "text-xl-bold": {
          "type": "custom-fontStyle",
          "value": {
            "fontSize": 18,
            "textDecoration": "none",
            "fontFamily": "Hiragino Kaku Gothic Pro",
            "fontWeight": 400,
            "fontStyle": "normal",
            "fontStretch": "normal",
            "letterSpacing": 0,
            "lineHeight": 30.6,
            "paragraphIndent": 0,
            "paragraphSpacing": 0,
            "textCase": "none"
          }
        }
      }
    }
  },
  "semantic": {
    // 色に関する情報
    "color": {
      "button": {
        "fill": {
          "yellow": {
            "description": "ボタンの訴求を強めたい場合、CTAでは無いが重要な意味を持つボタン用のカラー",
            "type": "color",
            "value": "{primitive.color.yellow.60}"
          },
          "cta-primary": {
            "description": "CTA専用のカラー。最も重要なアクションを示す場合に使用可",
            "type": "color",
            "value": "{primitive.color.navy.100}"
          }
        }
      }
    }
  }
}
```

# 書き出したJSONをCSSへ変換
先ほど書き出したjsonファイルをStyleDictionaryを使用して、CSSに変換しました。

> Style Dictionaryは、デザイントークンを一元管理するためのシステムです。デザイントークンとは、デザインの要素（色、サイズ、フォントなど）を表すための値です。これらのトークンはプロジェクト全体で一貫したデザインを保つのに役立ちます。
>
> Style Dictionaryを使用すると、これらのトークンを中央集権的な場所で管理し、多様なプラットフォームや言語に対応する形式で自動的に出力することができます。例えば、ウェブ、iOS、Android向けのスタイルを一つのソースから生成することができます。
>
> このシステムは、特に大きなチームや多くのプラットフォームをサポートする複雑なプロジェクトにおいて、デザインの一貫性を維持しながら効率的に作業するための強力なツールです。また、デザインの変更が生じた場合、中央集権的なトークンを更新するだけで、すべてのプラットフォームにわたって一貫した変更を適用することが可能になります。
>
> 【Chat GPTからの引用】

ここから、JSON形式で書き出されたデザイントークンをCSSに変換する一連の流れを紹介します。

# Style Dictionaryの設定
まず、Style Dictionaryの設定ファイル`config.json`を作成します。
この設定ファイルでは、変換元のトークンの場所と、変換後のファイルの出力形式、出力場所を指定します。

```json:config.json
{
  // DesignTokensで書き出したjsonファイルのパス
  "source": [".style-dictionary/tokens/**/*.json"],
  "platforms": {
    // scssファイルの出力
    "scss": {
      "transformGroup": "scss",
      "buildPath": "assets/design-tokens/scss/", //出力先
      "files": [
        {
          "destination": "_variables.scss",
          // css変数として出力する(StyleDictionaryで用意されているformat)
          "format": "scss/variables",
          // 「semantic」から始まる階層のみを変換対象にする
          "filter": {
            "path": ["semantic"]
          }
        },
        {
          "destination": "background-semantic-colors.scss",
          // 背景色のcssクラスとして出力する(独自のformat)
          "format": "scss/color-bg-classes",
          "filter": {
            // 「semantic.color」から始まる階層のみを変換対象にする
            "path": ["semantic", "color"]
          }
        },
        {
          "destination": "text-semantic-colors.scss",
          // 文字色のcssクラスとして出力する(独自のformat)
          "format": "scss/color-text-classes",
          "filter": {
            // 「semantic.color」から始まる階層のみを変換対象にする
            "path": ["semantic", "color"]
          }
        },
        {
          "destination": "font-style.scss",
        　　　　// フォントスタイルのcssとして出力する(独自のformat)
          "format": "scss/font-style-classes",
          "filter": {
            // 「font」から始まる階層のみを変換対象にする
            "path": ["font"]
          }
        }
      ]
    },
    // jsonファイルの出力
    "json": {
      "transformGroup": "assets",
      "buildPath": "assets/design-tokens/",
      "files": [{
        "destination": "tokens.json",
        "format": "json/nested",
        "filter": {
          "path": ["semantic"]
        }
      }]
    }
  }
}
```

[StyleDictionaryで用意されているformat](https://amzn.github.io/style-dictionary/#/formats)の他に、今回は実装で使用しやすいように独自のformatを用意しています。
独自のformatは、次の項目で実装します。

# Style Dictionaryの設定
次に、formatの定義をする`build.js`を作成します。
こまかな実装方法については省略しますが、実際に今回実装したコードを紹介します。

### 背景色のヘルパークラス生成
`.bg--{色の名前}` というクラス名で、cssを生成

```javascript:build.js(抜粋)
const StyleDictionary = require('style-dictionary')

StyleDictionary.registerFormat({
  name: 'scss/color-bg-classes',
  formatter(dictionary) {
    return dictionary.allProperties
      .map((prop) => {
        return `/* ${prop.description ?? prop.name} */
.bg--${prop.name} { background-color: ${prop.value} !important; }
`
      })
      .join('\n')
  },
})
```

### 文字色のヘルパークラス生成
`.text--{色の名前}`というクラス名でcssを生成

```javascript:build.js(抜粋)
// 文字色のヘルパークラスを生成
StyleDictionary.registerFormat({
  name: 'scss/color-text-classes',
  formatter(dictionary) {
    return dictionary.allProperties
      .map((prop) => {
        return `/* ${prop.description ?? prop.name} */
.text--${prop.name} { color: ${prop.value} !important; }
`
      })
      .join('\n')
  },
})
```

### フォントスタイルのヘルパークラス生成
`.font-${フォントスタイルの名前}`というクラス名でcssを生成

こちらは、レスポンシブに対応するため、プロジェクトで使用している[vuetifyのbreakpoints](https://vuetifyjs.com/en/features/display-and-platform/)のルールに合わせて、下記のバージョンのクラスも作成しています。

`.font-sm-${フォントスタイルの名前}` : smサイズ以上で適応されるクラス
`.font-md-${フォントスタイルの名前}` : mdサイズ以上で適応されるクラス
`.font-lg-${フォントスタイルの名前}` : lgサイズ以上で適応されるクラス
`.font-xl-${フォントスタイルの名前}` : xlサイズ以上で適応されるクラス


```javascript:build.js(抜粋)
// タイポグラフィのヘルパークラス
// font-text-..から始まる名前の場合、3つめの要素を削除してクラス名を生成する
// 例) font.text.text.text-s→ font-text-text-s
StyleDictionary.registerFormat({
  name: 'scss/font-style-classes',
  formatter(dictionary) {
    const styles = dictionary.allProperties
      .filter((prop) => !prop.name.includes('bold'))
      .map((prop) => {
        const propName = prop.name.split('-')[1] === 'text' ? prop.name.split('-').reduce((prev, current, index) => {
          if(index !== 2) {
            return prev + '-' + current
          } else {
            return prev
          }
        }) : prop.name
        return `/* ${prop.description ?? propName} */
.${propName} {
  font-size: ${prop.value.fontSize}px;
  font-weight: ${prop.value.fontWeight};
  line-height: ${prop.value.lineHeight}px;
  letter-spacing: ${prop.value.letterSpacing}px;
}`
      })
      .join('\n')

    const stylesSm = dictionary.allProperties
      .filter((prop) => !prop.name.includes('bold'))
      .map((prop) => {
      const propName = prop.name.split('-')[1] === 'text' ? prop.name.split('-').reduce((prev, current, index) => {
        if(index !== 2) {
          return prev + '-' + current
        } else {
          return prev
        }
      }) : prop.name
      const categoryName = propName.split('-')[0];
      const itemName = propName.slice(categoryName.length + 1)
        return `  /* ${prop.description ?? propName} sm */
  .${categoryName}-sm-${itemName} {
    font-size: ${prop.value.fontSize}px;
    font-weight: ${prop.value.fontWeight};
    line-height: ${prop.value.lineHeight}px;
    letter-spacing: ${prop.value.letterSpacing}px;
  }`
      })
      .join('\n')

    const stylesMd = dictionary.allProperties
      .filter((prop) => !prop.name.includes('bold'))
      .map((prop) => {
      const propName = prop.name.split('-')[1] === 'text' ? prop.name.split('-').reduce((prev, current, index) => {
        if(index !== 2) {
          return prev + '-' + current
        } else {
          return prev
        }
      }) : prop.name
      const categoryName = propName.split('-')[0];
      const itemName = propName.slice(categoryName.length + 1)
        return `  /* ${prop.description ?? propName} sm */
  .${categoryName}-md-${itemName} {
    font-size: ${prop.value.fontSize}px;
    font-weight: ${prop.value.fontWeight};
    line-height: ${prop.value.lineHeight}px;
    letter-spacing: ${prop.value.letterSpacing}px;
  }`
      })
      .join('\n')

    const stylesLg = dictionary.allProperties
      .filter((prop) => !prop.name.includes('bold'))
      .map((prop) => {
      const propName = prop.name.split('-')[1] === 'text' ? prop.name.split('-').reduce((prev, current, index) => {
        if(index !== 2) {
          return prev + '-' + current
        } else {
          return prev
        }
      }) : prop.name
      const categoryName = propName.split('-')[0];
      const itemName = propName.slice(categoryName.length + 1)
        return `  /* ${prop.description ?? propName} sm */
  .${categoryName}-lg-${itemName} {
    font-size: ${prop.value.fontSize}px;
    font-weight: ${prop.value.fontWeight};
    line-height: ${prop.value.lineHeight}px;
    letter-spacing: ${prop.value.letterSpacing}px;
  }`
      })
      .join('\n')

    const stylesXl = dictionary.allProperties
      .filter((prop) => !prop.name.includes('bold'))
      .map((prop) => {
      const propName = prop.name.split('-')[1] === 'text' ? prop.name.split('-').reduce((prev, current, index) => {
        if(index !== 2) {
          return prev + '-' + current
        } else {
          return prev
        }
      }) : prop.name
      const categoryName = propName.split('-')[0];
      const itemName = propName.slice(categoryName.length + 1)
        return `  /* ${prop.description ?? propName} sm */
  .${categoryName}-xl-${itemName} {
    font-size: ${prop.value.fontSize}px;
    font-weight: ${prop.value.fontWeight};
    line-height: ${prop.value.lineHeight}px;
    letter-spacing: ${prop.value.letterSpacing}px;
  }`
    })
    .join('\n')
  return `@use "@/assets/scss/_mediaquery.scss" as *;
  
${styles}

@include sm-up {
${stylesSm}
}

@include md-up {
${stylesMd}
}

@include lg-up {
${stylesLg}
}

@include xl {
${stylesXl}
}
`;
  },
})
```

# 変換の実行
設定が完了したら、Style Dictionaryを実行して、JSONファイルをCSSに変換します。

```shell
node .style-dictionary/build.js
```

実行後は、下記のようなファイルが生成されます。

```scss:assets/design-tokens/scss/_variables.scss
$semantic-color-icon-cta: #1c5a95ff;
$semantic-color-icon-yellow: #f8cc1dff;
$semantic-color-icon-black-secondary: #636366ff;
...
```

```scss:assets/design-tokens/scss/background-semantic-colors.scss
/* semantic-color-gen-plan-ichinichi-middle */
.bg--semantic-color-gen-plan-ichinichi-middle { background-color: #a17cabff !important; }

/* semantic-color-gen-plan-ichinichi-light */
.bg--semantic-color-gen-plan-ichinichi-light { background-color: #faf3ffff !important; }

/* semantic-color-gen-plan-ichinichi-superlight */
.bg--semantic-color-gen-plan-ichinichi-superlight { background-color: #faf3ffff !important; }
...
```

```scss:assets/design-tokens/scss/text-semantic-colors.scss
/* ボタンの訴求を強めたい場合、CTAでは無いが重要な意味を持つボタン用のカラー */
.text--semantic-color-button-fill-yellow { color: #fbde6dff !important; }

/* CTA専用のカラー。最も重要なアクションを示す場合に使用可 */
.text--semantic-color-button-fill-cta-primary { color: #1c5a95ff !important; }

/* ボタンのベーシックなボディカラー */
.text--semantic-color-button-fill-white { color: #ffffffff !important; }
...
```


```scss:assets/design-tokens/scss/font-style.scss
/* font-text-text-xl */
.font-text-text-xl {
  font-size: 18px;
  font-weight: 400;
  line-height: 30.6px;
  letter-spacing: 0px;
}
...

@include sm-up {
  /* font-text-text-xl sm */
  .font-sm-text-text-xl {
    font-size: 18px;
    font-weight: 400;
    line-height: 30.6px;
    letter-spacing: 0px;
  }
  ...
}

@include md-up {
  /* font-text-text-xl md */
  .font-md-text-text-xl {
    font-size: 18px;
    font-weight: 400;
    line-height: 30.6px;
    letter-spacing: 0px;
  }
  ...
}

@include lg-up {
  /* font-text-text-xl sm */
  .font-lg-text-text-xl {
    font-size: 18px;
    font-weight: 400;
    line-height: 30.6px;
    letter-spacing: 0px;
  }
  ...
}

@include xl {
  /* font-text-text-xl sm */
  .font-xl-text-text-xl {
    font-size: 18px;
    font-weight: 400;
    line-height: 30.6px;
    letter-spacing: 0px;
  }
  ...
}
```

# デザイントークンを導入してどう変わったか
### デザインを読み取る労力が減った
figmaから、色コードやフォントサイズを直接探すよりも、明示的な名前がついているため探す際の労力が減ったと感じています

### 色とフォントに名前がついているので、指定箇所がわかりやすい
ボタンに関する色なら `bg--semantic-color-btn...`、見出しに関するフォントなら `font-text-heading-...`のような名前がついているので、色コードやフォントサイズで指定するよりも、指定箇所がわかりやすくなりました

### 間違えにくくなった
色コード、フォントのピクセル数で指定している際よりもデザインの読み取りミスが発生しにくくなったと感じています。
また、間違えている場合も明示的な名前がついているので探すのが楽になりました。

# 最後に
デザイントークンの導入により、プロジェクトのデザイン一貫性が向上し、開発効率も改善されました。

コードの紹介が主になってしまいましたが、参考になれば幸いです。