FigmaからFlutterへ ── デザイントークン自動変換とUIカタログで実装を加速

FigmaからFlutterへ ── デザイントークン自動変換とUIカタログで実装を加速

はじめに

こんにちは、新規事業部フロントエンドブロックの安土琢朗です。普段はZOZOマッチのFlutterアプリ開発を担当しています。

ZOZOマッチは2025年6月にリリースされた、ゼロから立ち上げたマッチングアプリです。zozomatch.jp本記事では、開発初期から取り組んできたデザインシステムの導入によって、どのように開発効率とUIの品質を向上させたか、そしてそれを支える具体的な運用プロセスや仕組みについてご紹介します。

プロダクトを継続的に成長させるうえで、デザインと実装のズレを最小化し、誰が開発しても一定の品質が保たれる状態をどう実現するかは重要なテーマです。その課題に対し、私たちはデザイントークン・UIコンポーネント・UIカタログを軸にしたデザインシステムを構築しました。

目次

デザインシステム構築の背景と課題

デザインシステムとは

デザインシステムとは、デザイントークン(色、文字サイズ、余白など)、UIコンポーネント、ガイドライン、実装コードを統合的に管理する仕組みです。単なるスタイルガイドやコンポーネントライブラリではなく、デザインと実装の一貫性を保ちながら、チーム全体が効率的にプロダクトを開発・運用するための共通基盤となります。

なぜデザインシステムが必要だったのか

ZOZOマッチは、既存サービスの改修ではなく、ゼロから立ち上げた新規プロジェクトです。また、ZOZOにとって本格的なFlutterアプリ開発となるため、ノウハウ蓄積も重要な目的の1つでした。

新規プロジェクトは、レガシーコードに縛られず、最初から一貫した設計基盤を構築できる貴重な機会です。この機会を活かし、将来もスムーズに開発を続けられる仕組みとして、デザインシステムの導入を決めました。デザインと実装のズレを最小化し、再利用しやすい基盤づくりに注力することで、短期的な開発効率だけでなく、長期的な保守性も確保できると考えたからです。

直面していた課題

1. デザインの一貫性が保てない
  • 同じ「ボタン」でも画面ごとに角丸や余白が違う
  • テキストカラーが #2D2D2D#333333rgba(45,45,45,1) など複数存在
  • 実装に差分が生まれやすく、結果としてUIのバラつきが発生
2. デザイン変更にかかるコストが高い
  • ブランドカラーの変更に何十・何百箇所の修正が必要
  • 一部だけ反映して不整合が発生
  • 小さな変更でも「どこを直すか」が属人的
3. チーム内の認識のズレ
  • デザイナーとエンジニアの間で「どれが正しい仕様か」が曖昧
  • 「似たような画面を参考にしたら、実は別仕様だった」
  • 実装とFigmaの違いについて何度も確認が必要

これらの課題は、単なる効率の問題ではなく、プロダクトの品質とチームのモチベーションに直結する重要な問題でした。

解決策の全体像

これらの課題を解決するため、私たちは3つの仕組みを組み合わせたデザインシステムを構築しました。

1つ目は、デザイントークンの自動変換です。Figmaで定義した色、文字サイズ、余白などのデザイン要素を、Style Dictionaryパッケージを使ってDartコードに自動変換する仕組みを作りました。これにより、デザイナーがFigmaで値を更新すれば、エンジニアは自動生成されたコードを使うだけで、常に最新のデザインを反映できるようになりました。

2つ目は、再利用可能なコンポーネントの整備です。ボタン、カード、フォームなど、アプリ全体で使用する50以上のUIコンポーネントを統一的に実装しました。各コンポーネントはデザイントークンを使用しているため、一貫性のあるUIを簡単に構築できます。

3つ目は、Widgetbookによるコンポーネントカタログの構築です。すべてのUIコンポーネントをブラウザ上で確認できるカタログ環境を整備しました。実装者は、デザインの確認や状態のバリエーションを確認したいときに、このカタログを活用してコンポーネントの見た目を素早く把握できます。

この3つの仕組みが相互に連携することで、デザインと実装のギャップを解消し、UIの一貫性や開発スピードの向上、コンポーネント再利用の促進といった面で、チーム全体の生産性を着実に高めることができました。

具体的な実装と運用

ここからは、これらの仕組みをどのように実装し、日々の開発で運用しているかを具体的に紹介していきます。デザイントークンの自動変換プロセス、コンポーネント開発のワークフロー、そしてWidgetbookを使った品質管理の実践について、実際のコードや設定例を交えながら解説します。

1. デザイントークンの自動変換

ZOZOマッチでは、Figmaで管理しているデザイントークン(色、タイポグラフィ、余白、角丸など)をDartコードに自動変換する仕組みを構築しました。

定義しているトークンの種類

Color(色)

Colorトークンのギャラリー表示。
ブランドカラー、テキスト色、背景色など、アプリ全体で使用する色彩。

Typography(文字スタイル)

Typographyトークンの定義一覧。
フォントサイズ、ウェイト、行間などの文字に関する属性。

Spacing(余白)

Spacingトークンのサイズ一覧。
コンポーネント間の余白やパディングの値。

Radius(角丸)

Radiusトークンの角丸サイズ一覧。
ボタンやカードなどの角丸の半径。

Shadow(影)

Shadowトークンの影の定義一覧。
カードやモーダルなどに適用する影の定義。

階層的な命名規則により、ZNMColors.textPrimaryZNMUnit.unit16pxといった直感的な名前でトークンを参照できます。

変換プロセスの仕組み

なぜStyle Dictionaryを採用したのか

デザイントークンの運用で最も重要なのは「変更への対応力」です。私たちは以下の課題を解決したいと考えていました。

  1. デザイン変更の即座の反映

    • Figmaでカラーを変更したら、コマンド1つで全画面に反映したい
    • 手動で何十箇所も修正するのは非効率でミスの温床
  2. プラットフォーム固有の調整

    • FigmaのフォントウェイトとFlutterの見た目が異なる問題
    • 日本語フォントの扱いなど、実装特有の調整が必要
    • 将来的にAndroid/iOSで異なる調整が必要になる可能性
  3. 一貫性の担保

    • デザイナーが定義した値を、エンジニアが勝手に変更できない仕組み
    • 「正」はFigmaにあり、実装は常にそこから自動生成される状態

Style Dictionaryを選んだ理由は、これらの要件を満たすカスタマイズ性の高さにあります。Style Dictionaryは、Amazonが開発したデザイントークン変換ツールで、JSON形式のトークンを様々なプラットフォーム(iOS、Android、Web等)のコードに変換できます。単なる値の変換だけでなく、TypeScriptで独自の変換ロジックを実装でき、プロジェクト固有の要件に柔軟に対応できました。

技術スタック

  • Style Dictionary v5 - カスタマイズ可能なデザイントークン変換ツール
  • TypeScript - プロジェクト固有の変換ロジックを実装
  • Design Tokens Plugin - Figmaからデザイントークンをエクスポート

自動化のフロー

  1. Figmaでトークンを定義

    • デザイナーがカラーパレット、タイポグラフィなどを設定
  2. JSONとしてエクスポート

  3. Style Dictionaryで変換

    • TypeScriptでカスタム変換ロジックを実装
    • プラットフォーム固有の調整(フォントウェイト、日本語フォントなど)を適用
  4. Dartコードを自動生成

    • config.jsonで出力先とフィルタリングを設定
    • ZNMColorsZNMTypographyZNMUnitなどのクラスとして生成
    • 型安全で、IDEの補完が効くトークンクラスとして出力

運用フロー

# デザイナーがFigmaでトークンを更新した後
cd tools/design-token-to-dart
npm run build  # これだけで全トークンがDartコードに変換される

たった1コマンドで、Figmaの最新デザインがアプリ全体に反映されます。ブランドカラーの変更も、余白の調整も、すべて自動化されています。

実際の変換例

// tokens/design-tokens.tokens.json(Figmaからエクスポート)
{
  "color styles": {
    "text": {
      "primary": { "value": "#2D2D2D" }
    }
  },
  "typography": {
    "jp": {
      "bodyM": {
        "fontSize": 14,
        "fontWeight": 400,
        "lineHeight": 21
      }
    }
  }
}
// flutter.ts(カスタム変換ロジックの一部)
const formatTextStyle = (value: any, isJapanese: boolean): string => {
  // フォントウェイトの調整
  const fontWeight = value.fontWeight
    ? `fontWeight: FontWeight.w${adjustedWeight},`
    : null;

  // 日本語フォントは明示的に指定
  const fontFamily = isJapanese ? `fontFamily: 'Hiragino Sans',` : null;

  // lineHeightをheight比率に変換
  const height = value.lineHeight && value.fontSize
    ? `height: ${(value.lineHeight / value.fontSize).toFixed(2)},`
    : null;
  // ...
}
// 自動生成されるDartコード(znm_typography.g.dart)
class ZNMTypography {
  static TextStyle get jpBodyM {
    return const TextStyle(
      fontSize: 14,
      fontWeight: FontWeight.w400,
      fontFamily: 'Hiragino Sans',
      height: 1.50,  // lineHeight ÷ fontSize
    );
  }
}

実装ガイドライン

デザイントークンの導入により、ハードコードされた値から意味のある名前への移行が可能になりました。

Before:ハードコードされた値

Container(
  decoration: BoxDecoration(
    color: Color(0xFFFFFFFF),           // 毎回色コードを記載
    borderRadius: BorderRadius.circular(8), // マジックナンバー
  ),
  child: Text(
    'メッセージ',
    style: TextStyle(
      fontSize: 14,                     // サイズを直接指定
      color: Color(0xFF2D2D2D),         // また色コード...
    ),
  ),
)

After:デザイントークンを使用

Container(
  decoration: BoxDecoration(
    color: ZNMColors.surfacePrimary,        // 意味のある名前
    borderRadius: BorderRadius.circular(ZNMRadius.m), // 統一された角丸
  ),
  child: Text(
    'メッセージ',
    style: ZNMTypography.jpBodyM.copyWith(  // 定義済みスタイル + IDE補完
      color: ZNMColors.textPrimary,         // 一貫性のある色
    ),
  ),
)
  • セマンティックなトークン(textPrimary)を使用し、具体的な値(gray900)は避ける
  • コンポーネント単位でスタイルを適用(ZNMTypography.jpHeadingMをそのまま使用)
  • 特殊なケース以外では既存トークンの値を変更しない

この仕組みによって、コードの品質向上だけでなく、チームの働き方にもポジティブな変化が生まれました。

デザイントークンの自動変換で、Figmaの定義と実装コードが1:1で同期されます。レビューでは「Figmaのセマンティック名と一致するトークンが適用されているか」を基準に、エンジニアからデザイナーへ意見交換が自然に行き交うようになりました。

たとえばUI実装時、エンジニアはトークンの型付きコードを前提に作業するため、Figmaと照らし合わせる過程で「このUI、トークンが適用されていないかも」といった違和感にすぐ気づくことができます。これにより、チーム全体でトークンを使うのが前提という共通認識が育まれ、デザイナー側も一貫性を意識した設計がしやすくなりました。

2. 再利用可能なコンポーネントの整備

デザイントークンだけでは、一貫性のあるUIを構築するには不十分でした。同じトークンを使っていても、実装者によってコンポーネントの構造やインタラクションが異なれば、結局はバラバラなUIになってしまいます。

そこで私たちは、アプリ全体で使用する50以上のUIコンポーネントを、ドメインに依存しない汎用コンポーネントとして体系的に整備しました。

なぜドメインに依存しないコンポーネントが重要なのか

マッチングアプリには「ユーザー」「メッセージ」「いいね」といった固有のドメイン概念があります。しかし、UIコンポーネントがこれらの概念に直接依存してしまうと、以下の問題が生じます。

  • 再利用性が低下 -「ユーザー専用ボタン」は他の場所で使えない
  • テストが複雑 - ドメインロジックのモックが必要になる
  • 変更の影響範囲が広い - ドメインモデルの変更がUI全体に波及

そこで、コンポーネント層ではプリミティブな型(String、bool、VoidCallbackなど)のみを扱い、ドメインモデルとの変換は利用側で行う設計にしました。

コンポーネントの階層構造

packages/cores/designsystem/lib/components/
├── button/          # ボタン系(ZNMButton、ZNMActionButton など)
├── card/            # カード系
├── dialog/          # ダイアログ、モーダル
├── input/           # テキスト入力、検索バー
├── message/         # メッセージ表示(吹き出し、日付表示など)
├── navigation_bar/  # ナビゲーションバー
├── row/             # リスト行、チェックボックス、ラジオボタン
└── ...              # その他40以上のカテゴリ

各コンポーネントは、Figmaのコンポーネント階層と1:1で対応しています。これにより、デザイナーとエンジニアが「ZNMButtonprimarylargeサイズ」といった共通言語で会話できます。

コンポーネントの実装例

1. ボタンコンポーネント - 柔軟性と一貫性の両立

Figmaで定義されたZNMButtonコンポーネントのバリエーション。

このFigmaデザインを基に、以下のようにFlutterコンポーネントとして実装しています。

// ZNMButtonの実装(簡略版)
class ZNMButton extends HookWidget {
  factory ZNMButton({
    required String title,
    required ZNMButtonColor color,    // enum: primary, secondary, alert など
    required ZNMButtonSize size,      // enum: large, medium, small
    required VoidCallback? onPressed,
    bool isWidthFlexible = false,
  }) {
    // ...
  }

  // アイコン付きボタンのファクトリコンストラクタ
  factory ZNMButton.icon({
    required String title,
    required SvgGenImage svgImage,    // アイコン画像
    required ZNMButtonColor color,
    required ZNMButtonSize size,
    required VoidCallback? onPressed,
  }) {
    // ...
  }
}

設計のポイント

  • Enumによる選択肢の制限 - 無秩序なカスタマイズを防ぐ
  • ファクトリコンストラクタ - バリエーションを明確に定義
  • プリミティブな型 - ドメインモデルに依存しない

コンポーネント開発のワークフロー

1. Figmaからの仕様確認

デザイナーがFigmaでコンポーネントを定義する際、以下の情報を明確にします。

  • バリエーション - カラー、サイズ、状態のパターン
  • インタラクション - タップ、長押し、スワイプなどの挙動
  • アニメーション - 遷移時の動き、ローディング表示

2. 汎用コンポーネントとしての実装

別の例として、カードコンポーネントの実装を見てみましょう。こちらもドメインに依存せず、汎用的に使えるよう設計されています。

// ドメインに依存しない汎用的な実装
class ZNMCard extends StatelessWidget {
  const ZNMCard({
    required Widget child,
    Color? backgroundColor,
    double? elevation,
    VoidCallback? onTap,
  });
  
  @override
  Widget build(BuildContext context) {
    return Card(
      color: backgroundColor ?? ZNMColors.surfacePrimary,
      elevation: elevation ?? ZNMShadows.cardElevation,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(ZNMRadius.m),
      ),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(ZNMRadius.m),
        child: child,
      ),
    );
  }
}

この体系的なコンポーネント整備により、新しい画面を実装する際も、既存のコンポーネントを組み合わせるだけで、一貫性のあるUIを素早く構築できるようになりました。

3. Widgetbookによるコンポーネントカタログ

Widgetbookとは

Widgetbookは、Flutterアプリケーション用のUIカタログツールです。StorybookのFlutter版とも言える存在で、コンポーネントを独立した環境でプレビュー・テストできます。

私たちがWidgetbookを選んだ理由は、以下のような特徴があるためです。

  1. ブラウザ上での確認 - デザイナーもアプリをインストールせずに確認可能
  2. インタラクティブな操作 - パラメータを動的に変更して挙動を確認
  3. デバイスプレビュー - iPhone、Android、iPadなど様々な画面サイズで確認
  4. 自動生成 - アノテーションベースでカタログを自動構築

実際のWidgetbookカタログの画面です。左側にコンポーネントのツリー、中央にプレビュー、右側にKnobsパネルが表示されています。

Widgetbookカタログの画面構成(左:コンポーネントツリー、中央:プレビュー、右:Knobsパネル)。

ZOZOマッチでの実装

カタログアプリの構成

apps/catalog/
├── lib/
│   ├── main.dart              # Widgetbookアプリのエントリーポイント
│   └── use_case/
│       ├── cores/
│       │   └── designsystem/
│       │       └── components/ # 20カテゴリ・50以上のコンポーネント
│       └── features/          # 各機能のUI
├── goldens/                   # ゴールデンテスト用のスナップショット
└── test/                      # ビジュアルリグレッションテスト

Widgetbookアプリの設定

// main.dart
@App()
class WidgetbookApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Widgetbook.material(
      directories: directories,  // 自動生成されたディレクトリ構造
      addons: [
        // テーマの切り替え(Light/Dark)
        MaterialThemeAddon(
          themes: [
            WidgetbookTheme(name: 'Light', data: lightTheme()),
            WidgetbookTheme(name: 'Dark', data: darkTheme()),
          ],
        ),
        // デバイスプレビュー
        ViewportAddon([
          IosViewports.iPhone13,
          IosViewports.iPhoneSE,
          IosViewports.iPad,
          ...AndroidViewports.phones,
        ]),
        // UIインスペクター
        InspectorAddon(),
        // 多言語対応
        LocalizationAddon(
          locales: [...DesignSystemL10n.supportedLocales],
          localizationsDelegates: [...],
        ),
      ],
    );
  }
}

コンポーネントのUse Case実装例

// znm_button.dart
@UseCase(name: 'ZNMButton', type: ZNMButton)
Widget znmButtonUseCase(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('ZNMButton Sample')),
    body: SingleChildScrollView(
      child: Column(
        children: ZNMButtonSize.values.map((size) =>
          ZNMButton(
            // Widgetbookのknobs機能を使って、UIから動的に値を変更可能
            color: context.knobs.dropdown(
              label: 'color',
              options: ZNMButtonColor.values,
              initialOption: ZNMButtonColor.primary,
            ),
            size: size,
            title: context.knobs.string(
              label: 'title',
              initialValue: 'テキスト',
            ),
            onPressed: context.knobs.boolean(label: 'enabled')
              ? () => log('pressed')
              : null,
          ),
        ).toList(),
      ),
    ),
  );
}

Widgetbookの活用効果

1. 実装前の確認フロー

新しいコンポーネントを実装する際の確認フローが確立されました。

  1. デザイナーがFigmaで作成
  2. エンジニアが実装
  3. Widgetbookで確認 - デザイナーがブラウザで挙動を確認
  4. フィードバック反映 - その場で調整が必要な箇所を特定

2. バリエーションの網羅的な確認

// すべての組み合わせを一覧で確認
@UseCase(name: 'All Variations', type: ZNMButton)
Widget allVariationsUseCase(BuildContext context) {
  return GridView.builder(
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3,
    ),
    itemCount: ZNMButtonColor.values.length * ZNMButtonSize.values.length,
    itemBuilder: (context, index) {
      final colorIndex = index ~/ ZNMButtonSize.values.length;
      final sizeIndex = index % ZNMButtonSize.values.length;

      return ZNMButton(
        color: ZNMButtonColor.values[colorIndex],
        size: ZNMButtonSize.values[sizeIndex],
        title: 'Button',
        onPressed: () {},
      );
    },
  );
}

3. ドキュメントとしての価値

Widgetbookは生きたドキュメントとして機能します。

  • 新メンバーのオンボーディング - UIコンポーネントの全容を把握
  • 実装者の参考 - 使い方や制約事項を実際に動かして確認
  • デザインレビュー - 実装がデザイン通りか即座に確認

デプロイと共有

Widgetbookはブラウザで動作するため、GitHub Pagesにデプロイして社内で共有しています。

# ビルドコマンド
flutter build web --target lib/main.dart

# GitHub Actionsでの自動デプロイ
# main/developブランチへのマージ時に自動更新

これにより、最新のUIカタログが常にブラウザからアクセス可能な状態を保っています。デザイナーとエンジニアの両方が、URLを開くだけで最新のコンポーネントを確認できます。

まとめ

本記事では、デザインと実装のズレをなくすために導入したデザインシステムと、その具体的な運用例を紹介しました。今後は、Figma MCPなどの仕組みも取り入れながら、Figmaからの実装効率をさらに高めていきたいと考えています。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

corp.zozo.com

カテゴリー