静的なFigmaデザインから動的なUIを生成 〜AIに"動き"を伝える「アノテーション駆動開発」〜
こんにちは、Ryotaです🐶
ときはAIエージェント元年。
生成AIの急速な進化により、ソフトウェア開発の常識は大きく塗り替えられつつあります。
弊社でもこの潮流をチャンスと捉え、社内AIエージェント開発プロジェクト を立ち上げました。
反復的なコーディング作業をAIに任せることで開発効率を飛躍的に高め、エンジニアが「機能設計」や「ユーザー体験の向上」といった、よりクリエイティブな上流工程に集中できる環境を目指しています。
本記事でご紹介する「アノテーション駆動開発」は、AIエージェントによるUI自動実装の重要なマイルストーンです。
これまで多くの工数を要してきたUI実装を、AIエージェントの力で驚くほど短時間に完了できる新しいワークフローを、ぜひ皆さんも体験してみてください。
はじめに
本記事ではフロントエンドのAIエージェント作成においての壁、どのように動的UIを実現したのかを、社内システムに新たに導入予定の「稟議申請・承認」システムの実装実例と共にご説明します。
サーバーサイドのAIエージェントに関しては別途またどこかで...どなたかが...笑
ということで、具体的な実装手順や得られるメリット等、ぜひ最後までお付き合いくださいませ✨
開発環境と使用ツール
今回はあくまで動的UIの実装に関してなので、技術スタックの細かい内容は省略しますが、主に以下を使用して開発しています。
-
AIエージェント本体。VS Code拡張やCLIで動的UIの自動生成を担当。
-
GitHubでたてたイシュー内容を取得するのに使用。
イシューには以下を記載する。- 作成するページのパス
- Figma URL
- ページの仕様
-
Figmaからデータを取得するためのツール。
(Dev Mode MCPサーバーはFigmaデスクトップアプリからのみ使用可能) -
Figmaデザインと実装されたページ・コンポーネントの一致確認、スクリーンショット撮影による視覚的検証に利用
-
フロントエンドの標準スタック。ファイルベースルーティングは TanStack Routerを利用。
-
パッケージ管理、スクリプト実行、型チェックなどに使用。
動的挙動再現の壁
Figmaは非常に優れたデザインツールであり弊社でも重宝していますが、あくまでデザイナーが作成したデザインを共有するためだけの、静的な「絵」に過ぎません。
そのため読み取ったデータを元にAIで実装をする場合、簡単なコンポーネントを並べるだけの画面であれば問題ないのですが、ホバー、ドラッグ&ドロップ、開閉、権限による表示切り替え、等々の状態変化を伴うUIとなると到底プロンプトでも対応しきれず手に負えませんでした🙌💦
実際に膨大な量のプロンプトを渡して作成した成果物は...
- UI崩れてる
- スライドしない
- ホバーしない
- フィルター機能がない
- ページネーションとして動かない(というかページネーションどこw)
といった感じでやはり動的な挙動まではAIには理解できず💦
state管理から何から全て手作業で追加、もしくは都度AIに指示を出しては修正を繰り返す羽目になるのでした。
ムキーーーーーー🐵💢
Figma MCP のアップデート
どうにかしてFigmaを読み取るだけで、人間に指示を出しているかの如く、当たり前の挙動をうまく汲み取ってもらう方法はないだろうか?
と調べていたところ、なんとこんな記事が...
訳:Dev Mode MCPサーバーの新機能:デザインのコンテキストとしてアノテーションが含まれるようになりました。
生成されたコードは、デザインの構造とデザインの意図の両方から恩恵を受けています。
え!!!アノテーション読み取れるようになってるやん!!!!!
ということで、Figmaに細かい指示を書き込めばCloude Code側で読み取れるようになったことが判明しました。
アノテーションフル活用✨
そうとなれば、アノテーションを 「動的挙動の仕様書」 という位置付けにすれば良いのです。
具体的には以下のフローで実装します。
1. Figmaに直接アノテーションで挙動指示を書く
Figmaのアノテーション機能を使用してこんな感じでガンガンコメントします。
コツとしては
- 対象のコンポーネントを明確にする
- 項目ごとにmd記法で箇条書きリストにしてあげる
これらを意識することでAIも理解しやすくなります。
2. GitHubでイシュー作成 → 取得
該当のFigmaデザインへのリンクをコピーしイシューに貼り付けます。
このイシューに記載した内容をGitHub mcp のmcp__github__get_issue
で取得します。
data-annotation
を読み取り、チェックリストを作成し実装
3. Claude Codeが AIは時に仕事をサボります。
そのため徹底してサボらないように以下の内容をCLAUDE.md
に記載します。
(勿論プロンプトファイルは分けてもOK!皆さんにお任せします)
-
自動実行する内容
- Figmaデザインの解析: デザインファイルから全
data-annotations
を抽出 - 要件のリスト化: 各アノテーションを実装タスクとして変換
- 進捗管理: TodoWriteツールで実装進捗を可視化
- Figmaデザインの解析: デザインファイルから全
-
実装開始前の必須ステップ
- Figmaアノテーション要件をチェックリスト化
- 各要件を個別のタスクとして管理
- 全要件完了まで実装継続を義務化
-
禁止事項
- チェックリスト作成のスキップ
- アノテーションを無視した実装
- 要件未完了での実装終了
実際はもう少し細かいですが、これらの内容を明記することにより、実装中にAI自身で自ら以下のようなチェックリストを管理し、実装するようになりました。
4. 成果物の確認
-
セレクトボックス/チェックボックスの状態管理
デザイナーの意図どおり、選択肢の追加・削除・排他制御を正しく反映 -
はみ出しデータの縦スクロール
テーブル行のコンテンツが枠を超えた場合にスムーズにスクロール
(試すの忘れてますがちゃんとできてます😅) -
アノテーション指定の動的挙動
セレクトボックスの開閉、選択肢の内容など細かな指定を忠実に再現 -
モックデータ定義
開発中のAPI連携を見据え、最初からモックデータを構造化して生成
これらすべてがAIエージェントによって自動生成され、モノの5分程度で動作するUIが完成しました👏
追記:以下実装されたコードです。
実装コード
n18next
という多言語対応するためのライブラリ等使用していますが、基本的に全てプロンプトを用意して対応しています。importの書き方等細かいところは要修正ですね。
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import React, { useReducer, useMemo, useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { DropdownForm } from '@/components/modules/DropdownForm';
import { Pagination } from '@/components/modules/Pagination';
// 定数定義
const ITEMS_PER_PAGE = 10;
const PRICE_THRESHOLD = 50000;
// 型定義
type UserRole = 'ADMIN' | 'MANAGER' | 'MEMBER' | 'PART_TIME';
type Manager = {
id: number;
name: string;
level: number;
};
type ApprovalRequest = {
id: number;
status: '未承認' | '承認済み';
userName: string;
manager: Manager[];
price: number;
createdAt: string;
};
// フィルター状態の型定義
type FilterState = {
statusFilter: string;
priceFilter: string;
showMyPendingOnly: boolean;
currentPage: number;
};
// アクション型定義
type FilterAction =
| { type: 'SET_STATUS_FILTER'; payload: string }
| { type: 'SET_PRICE_FILTER'; payload: string }
| { type: 'SET_MY_PENDING_ONLY'; payload: boolean }
| { type: 'SET_CURRENT_PAGE'; payload: number };
// 初期状態
const initialFilterState: FilterState = {
statusFilter: 'すべて',
priceFilter: 'すべて',
showMyPendingOnly: false,
currentPage: 1
};
// reducer関数
const filterReducer = (state: FilterState, action: FilterAction): FilterState => {
switch (action.type) {
case 'SET_STATUS_FILTER':
return { ...state, statusFilter: action.payload, currentPage: 1 };
case 'SET_PRICE_FILTER':
return { ...state, priceFilter: action.payload, currentPage: 1 };
case 'SET_MY_PENDING_ONLY':
return { ...state, showMyPendingOnly: action.payload, currentPage: 1 };
case 'SET_CURRENT_PAGE':
return { ...state, currentPage: action.payload };
default:
return state;
}
};
// モックデータ
const mockApprovalRequests: ApprovalRequest[] = [
{
id: 1,
status: '未承認',
userName: '高橋 花子',
manager: [
{ id: 1, name: '鈴木 太郎', level: 1 },
{ id: 2, name: '田中 花子', level: 2 }
],
price: 5000,
createdAt: '2025/6/23'
},
{
id: 2,
status: '未承認',
userName: '高橋 花子',
manager: [{ id: 1, name: '鈴木 太郎', level: 1 }],
price: 1200,
createdAt: '2025/6/25'
},
{
id: 3,
status: '未承認',
userName: '高橋 花子',
manager: [{ id: 1, name: '鈴木 太郎', level: 1 }],
price: 15000,
createdAt: '2025/7/1'
},
{
id: 4,
status: '承認済み',
userName: '高橋 花子',
manager: [{ id: 1, name: '鈴木 太郎', level: 1 }],
price: 2500,
createdAt: '2025/7/1'
},
{
id: 5,
status: '未承認',
userName: '高橋 花子',
manager: [{ id: 1, name: '鈴木 太郎', level: 1 }],
price: 1250,
createdAt: '2025/7/1'
},
{
id: 6,
status: '未承認',
userName: '伊藤 一太',
manager: [
{ id: 1, name: '鈴木 太郎', level: 1 },
{ id: 3, name: '佐藤 一郎', level: 2 }
],
price: 30000,
createdAt: '2025/7/2'
},
{
id: 7,
status: '未承認',
userName: '山本 二郎',
manager: [
{ id: 1, name: '鈴木 太郎', level: 1 },
{ id: 3, name: '佐藤 一郎', level: 2 }
],
price: 10000,
createdAt: '2025/7/3'
},
{
id: 8,
status: '未承認',
userName: '中村 三郎',
manager: [
{ id: 1, name: '鈴木 太郎', level: 1 },
{ id: 3, name: '佐藤 一郎', level: 2 }
],
price: 5000,
createdAt: '2025/7/4'
},
{
id: 9,
status: '未承認',
userName: '佐藤 一郎',
manager: [{ id: 1, name: '鈴木 太郎', level: 1 }],
price: 4800,
createdAt: '2025/7/5'
},
{
id: 10,
status: '未承認',
userName: '渡辺 四郎',
manager: [
{ id: 1, name: '鈴木 太郎', level: 1 },
{ id: 2, name: '田中 花子', level: 2 }
],
price: 6000,
createdAt: '2025/7/6'
}
];
// フィルターオプション
const statusOptions = [
{ value: 'すべて', label: 'すべて' },
{ value: '未承認', label: '未承認' },
{ value: '承認済み', label: '承認済み' }
];
const priceOptions = [
{ value: 'すべて', label: 'すべて' },
{ value: `${String(PRICE_THRESHOLD)}円以上`, label: `${String(PRICE_THRESHOLD)}円以上` },
{ value: `${String(PRICE_THRESHOLD)}円未満`, label: `${String(PRICE_THRESHOLD)}円未満` }
];
export const Route = createFileRoute('/$lang/_authenticated/approval-requests-test/')({
component: ApprovalRequestsTestPage
});
function ApprovalRequestsTestPage() {
const { lang } = Route.useParams();
const { t } = useTranslation('translation', { lng: lang });
const navigate = useNavigate();
// モックのユーザーロール
const [userRole] = useState<UserRole>('ADMIN');
// フィルター状態(useReducerで統合)
const [filterState, dispatch] = useReducer(filterReducer, initialFilterState);
const { statusFilter, priceFilter, showMyPendingOnly, currentPage } = filterState;
// React Hook Form
const { register } = useForm();
// フィルタリング関数
const applyStatusFilter = useCallback(
(item: ApprovalRequest) => {
return statusFilter === 'すべて' ? true : item.status === statusFilter;
},
[statusFilter]
);
const applyPriceFilter = useCallback(
(item: ApprovalRequest) => {
return priceFilter === 'すべて'
? true
: priceFilter === `${String(PRICE_THRESHOLD)}円以上`
? item.price >= PRICE_THRESHOLD
: priceFilter === `${String(PRICE_THRESHOLD)}円未満`
? item.price < PRICE_THRESHOLD
: true;
},
[priceFilter]
);
const applyMyPendingFilter = useCallback(
(item: ApprovalRequest) => {
// 実際の実装では現在ユーザーのIDを使用
return showMyPendingOnly
? item.manager.some(manager => manager.id === 1) && item.status === '未承認'
: true;
},
[showMyPendingOnly]
);
// データフィルタリング
const filteredData = useMemo(() => {
return mockApprovalRequests.filter(
item =>
applyStatusFilter(item) &&
applyPriceFilter(item) &&
applyMyPendingFilter(item)
);
}, [applyStatusFilter, applyPriceFilter, applyMyPendingFilter]);
// ページネーション計算
const paginationData = useMemo(() => {
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentData = filteredData.slice(startIndex, endIndex);
const displayStart = filteredData.length > 0 ? startIndex + 1 : 0;
const displayEnd = Math.min(endIndex, filteredData.length);
return {
totalPages,
currentData,
displayStart,
displayEnd
};
}, [filteredData, currentPage]);
const { totalPages, currentData, displayStart, displayEnd } = paginationData;
// イベントハンドラー
const handlePageChange = (page: number) => {
dispatch({ type: 'SET_CURRENT_PAGE', payload: page });
};
const handleApprovalSetting = () => {
console.log('承認者設定画面への遷移');
};
const handleCreateRequest = () => {
navigate({
to: `/${lang}/approval-request/create`,
params: { lang }
});
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_MY_PENDING_ONLY', payload: e.target.checked });
};
const handleStatusFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
dispatch({ type: 'SET_STATUS_FILTER', payload: e.target.value });
};
const handlePriceFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
dispatch({ type: 'SET_PRICE_FILTER', payload: e.target.value });
};
const handleRowClick = (requestId: number) => {
console.log(`詳細ページに遷移: ${String(requestId)}`);
};
return (
<div className='bg-gray-1 flex items-center justify-center min-h-screen p-0'>
<div className='bg-white rounded-3xl shadow-lg w-[800px] h-[803px] p-10 overflow-hidden'>
{/* タイトル */}
<div className='flex justify-center pb-10'>
<h1 className='text-pc-heading-l font-bold text-gray-5'>
{t('購買稟議申請・承認')}
</h1>
</div>
{/* ページヘッダー */}
<div className='flex items-center justify-between mb-3'>
<h2 className='text-pc-body-m text-gray-5'>{t('購買申請一覧')}</h2>
<div className='flex items-center gap-4'>
{userRole === 'ADMIN' && (
<button
className='bg-red-0 text-white px-6 py-2 rounded-full font-medium text-pc-button-label'
onClick={handleApprovalSetting}
>
{t('承認者設定')}
</button>
)}
<button
className='bg-primary text-white px-6 py-2 rounded-full font-medium text-pc-button-label'
onClick={handleCreateRequest}
>
{t('稟議申請作成')}
</button>
</div>
</div>
{/* 区切り線 */}
<div className='h-px w-full bg-gray-4 mb-3' />
{/* フィルター */}
<div className='flex items-center justify-end gap-6 mb-4 pt-9 pb-2'>
{(userRole === 'ADMIN' || userRole === 'MANAGER') && (
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-5'>{t('自分が未承認の申請')}</span>
<input
type='checkbox'
className='w-4.5 h-4.5'
checked={showMyPendingOnly}
onChange={handleCheckboxChange}
/>
</div>
)}
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-5'>{t('ステータス')}</span>
<div className='relative'>
<DropdownForm
variant='user'
register={register('statusFilter')}
options={statusOptions}
className='border border-gray-4 rounded-[10px] pl-5 pr-8 py-1 text-sm min-w-[80px]'
onChange={handleStatusFilterChange}
value={statusFilter}
/>
</div>
</div>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-5'>{t('金額')}</span>
<div className='relative'>
<DropdownForm
variant='user'
register={register('priceFilter')}
options={priceOptions}
className='border border-gray-4 rounded-[10px] pl-5 pr-8 py-1 text-sm min-w-[80px]'
onChange={handlePriceFilterChange}
value={priceFilter}
/>
</div>
</div>
</div>
{/* テーブル */}
<div className='flex-1 overflow-hidden'>
<div className='grid grid-cols-5 w-full'>
{/* ヘッダー行 */}
<div className='p-2 border-b-2 border-gray-3 bg-white text-center font-bold text-sm text-gray-5'>
{t('ステータス')}
</div>
<div className='p-2 border-b-2 border-gray-3 bg-white text-center font-bold text-sm text-gray-5'>
{t('申請者')}
</div>
<div className='p-2 border-b-2 border-gray-3 bg-white text-center font-bold text-sm text-gray-5'>
{t('承認者')}
</div>
<div className='p-2 border-b-2 border-gray-3 bg-white text-center font-bold text-sm text-gray-5'>
{t('申請金額')}
</div>
<div className='p-2 border-b-2 border-gray-3 bg-white text-center font-bold text-sm text-gray-5'>
{t('申請日')}
</div>
{/* データ行 */}
{currentData.map(item => (
<React.Fragment key={item.id}>
<div
className='p-2 border-b border-gray-1 bg-white hover:bg-gray-1 cursor-pointer text-center text-sm text-gray-5 transition-colors duration-200'
onClick={() => handleRowClick(item.id)}
>
{item.status}
</div>
<div
className='p-2 border-b border-gray-1 bg-white hover:bg-gray-1 cursor-pointer text-center text-sm text-gray-5 transition-colors duration-200'
onClick={() => handleRowClick(item.id)}
>
{item.userName}
</div>
<div
className='p-2 border-b border-gray-1 bg-white hover:bg-gray-1 cursor-pointer text-center text-sm text-gray-5 transition-colors duration-200'
onClick={() => handleRowClick(item.id)}
>
<div className='flex flex-col'>
{item.manager.map(manager => (
<div key={manager.id}>{manager.name}</div>
))}
</div>
</div>
<div
className='p-2 border-b border-gray-1 bg-white hover:bg-gray-1 cursor-pointer text-center text-sm text-gray-5 transition-colors duration-200'
onClick={() => handleRowClick(item.id)}
>
¥ {item.price.toLocaleString()}
</div>
<div
className='p-2 border-b border-gray-1 bg-white hover:bg-gray-1 cursor-pointer text-center text-sm text-gray-5 transition-colors duration-200'
onClick={() => handleRowClick(item.id)}
>
{item.createdAt}
</div>
</React.Fragment>
))}
</div>
</div>
{/* ページネーション */}
<div className='flex justify-center pt-6'>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredData.length}
displayStart={displayStart}
displayEnd={displayEnd}
onPageChange={handlePageChange}
lang={lang}
className='gap-16'
/>
</div>
</div>
</div>
);
}
まとめ
「アノテーション駆動開発」を取り入れることで、Figma上に書き込んだ動的挙動の指示をそのままAIエージェントがコードに変換し、デザインからコーディングまでの一連の流れを自動化できるようになりました。
今回紹介はしていませんが、型定義や既存コンポーネントの再利用等の指示もプロンプト化しており、より精度の高い成果をあげることが可能になっています。
実務デビューはまだですが、これまでコーディングにかかっていた一人当たりの開発コストは大幅削減が見込め、開発効率も爆上がりすること間違いなしです✨
ぜひ皆さんも、アノテーションを活用したAI自動実装の新しいワークフローを試してみてください😆
おわりに
弊社ではカジュアル面談を実施しています。
少しでも興味を持っていただけた方は、ぜひ以下のリンクからご連絡ください!
Discussion