
プロローグ
河野「このTODOアプリ便利やなぁ」
河野「でも、なんかシンプルすぎて退屈やねんなぁ。たまにTODO忘れるし...」
河野「もっとド派手なハリウッド映画みたいなデザインで緊迫感だしてくれや!」
アプリ「わかりました。もっと激しくてインパクトのあるデザインで訴求します。」
アプリ「ウィーン。ガシャコン。キュイーン⤴︎(デザインが変わる音)」
アプリ「はい。河野さん好みのデザインにしておきました。(ハリウッドの爆弾バーン!!)」
河野「そうそう。素直でええねん。これでわかりやすくなった!」
河野「あ〜あ!。あらゆるアプリがユーザーごとにカスタマイズされたデザインになる世界に生まれて良かった!!!」
おや...間違って少し未来の話を見せてしまいました。
これでは未来が変わってしまうかもしれない...まずい...タイムパラドックスだ!
もう知られてしまったからには仕方がありません。
これからあなた方には生成UIを実現する秘密結社、「生成UIで世界を変え隊なぁ」の一員として暗躍してもらうことにします。
まずは入隊の儀式として、2025年7月時点の私の研究結果を共有したいと思います。
劇場版 「ジェ。」-生成AIとUIの関係について- エピソード5: AIの逆襲
自己紹介
カミナシ StatHackカンパニー のかわりくです。
日頃の悪行は以下で知ることができます!
kaminashi-developer.hatenablog.jp
本題
生成UI、ご存知でしょうか?
Vercelは、同社のブログ記事「Announcing v0: Generative UI」で、Generative UIを次のように説明しています。
A few weeks ago, we introduced v0: a product that makes website creation as simple as describing your ideas. We call it Generative UI—combining the best practices of frontend development with the potential of generative AI.
日本語訳: 数週間前、私たちはv0を発表しました。これは、アイデアを説明するのと同じくらい簡単にウェブサイトを作成できる製品です。私たちはこれを生成UIと呼んでおり、フロントエンド開発のベストプラクティスと生成AIの可能性を組み合わせたものです。
出典: Vercel - Announcing v0: Generative UI
Nielsen Norman Groupは記事「Generative UI and Outcome-Oriented Design」の中で、よりユーザー体験に焦点を当てた定義をしています。
A generative UI is a user interface that is dynamically generated in real time by artificial intelligence to provide an experience customized to fit the user's needs and context.
日本語訳: 生成UIとは、ユーザーのニーズとコンテキストに合わせてカスタマイズされたエクスペリエンスを提供するために、AIによってリアルタイムで動的に生成されるユーザーインターフェースです。
出典: Nielsen Norman Group - Generative UI and Outcome-Oriented Design
V0をはじめとして、ClaudeのArtifactやChatGPTのCanvasなどで
「アイデアを説明するのと同じくらい簡単にウェブサイトの作成をすること」
はすでに実現しつつあると言えるでしょう。
少なくとも趣味や個人レベルで体験された方は多いのではないでしょうか?
一方で、 デプロイされ実際にエンドユーザーが操作しているアプリケーション上でUIが生成されながらリアルタイムに変化する。といった体験はまだあまり実現されていないように思います。(以降、本記事での生成UIはこちらを指します。)
おそらく数年以内にはこのような体験を実現するライブラリやツール、サービスが登場するでしょう。
しかし、このような未来が待ち遠しくてたまらない私は、ひと足先に体験すべく、実験的に生成UIを実装してみました。
前提
まず、カミナシで日頃開発している「AIラベル検査」のとある画面を簡略化して用意しました。
よくあるマスタ一覧、リストのUIです。

このUIに対してユーザーの要求を元にLLMが動的に生成したUIを描画するのが今回のゴールです。
まずは結果
1. テーマの変更
プロンプト
ダークモードにして欲しい、白文字に見やすい黒背景
2. レイアウトの変更
プロンプト
カードUIにして欲しい
3.文字サイズの変更
プロンプト
フォントサイズを結構大きくして欲しいです。目が悪いので...
4. データのフィルタリング
プロンプト
1. ラベル管理番号が200のデータが見たい 2. 非表示状態のデータを見せて欲しい
5. 1から4全部盛り
プロンプト
カードUIで、タークモードで、フォントサイズは大きく、ラベル検査名にコピーが含まれるデータが見たい
感想
す、すごい...!
あまり派手な変化はないので驚きは少ないかもですが、ブラウザでリアルタイムにUIを変化させることができました。
生成AIが派手なECサイトを一瞬で生み出す。という様子はよく見ますが、データ構造を変えてみやすくしたり、アクセシビリティのためにユーザーにカスタマイズしたUIを生成するというのはまだ浸透していないように思えます。
私にとっては後者の方がより興味深く、身近なテーマになるので(まだとても荒削りですが)実現できて感動しました。
補足
見やすくする為にUIが生成されるまでの間を一部カットしています。
動画上ではとんでもないスピードで生成されているように見えますが、平均的に30秒ほどかかっています。(モデルやリクエスト依存)
実装方法
概要
ライブラリなど
- Next.js
- Material-UI (MUI)
- openai
- モデル:GPT-4o
LLMが生成したUIを描画する方法はいくつかありますが、今回はMUIコンポーネントをツリー構造のNode(以降、Nodeツリーと呼ぶ)として定義し、LLMにそのNodeツリーを生成させています。そして、そのNodeツリーを再帰的にパースして、実際のMUIコンポーネントとして描画する仕組みを実装しました。
注意
PoCとして、ローカルで実行することを前提にしている為、プロダクションでそのまま活用することは危険です!!! セキュリティ面やパフォーマンス面でのさらなる検討の加速が必要です。
LLMへのリクエスト
'use server'; import { getMasterData } from '@/fetcher'; import { AVAILABLE_COMPONENTS, DEFAULT_TABLE_TEMPLATE, type ComponentNode, } from './componentProtocol'; import OpenAI from 'openai'; type Response = { success: true; componentTree: ComponentNode; } | { success: false; error: string; }; export async function generateUI(userInput: string): Promise<Response> { // マスターデータを取得 const masterData = await getMasterData(); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // プロンプト構築 const systemPrompt = ` Material-UI (MUI) を使用してUIを設計してください。 以下のJSON形式で生成してください: { "type": "コンポーネント名", "props": { "sx": { /* スタイル */ } }, "children": [] } 利用可能なコンポーネント: ${Object.keys(AVAILABLE_COMPONENTS).join(', ')} 特にスタイリングの変更が求められない時は、デフォルトテンプレートを使用してください。 テンプレートに含まれる{}のテンプレートの部分は、実際のデータに置き換えてください。 デフォルトテンプレート: ${JSON.stringify(DEFAULT_TABLE_TEMPLATE, null, 2)} `; const prompt = ` 利用可能なデータ: ${JSON.stringify(masterData, null, 2)} ユーザーリクエスト: ${userInput} 上記データを使用してUIを生成してください。 `; try { const response = await openai.chat.completions.create({ model: 'gpt-4o', messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: prompt } ], response_format: { type: 'json_object' }, temperature: 0.7, }); const content = response.choices[0]?.message?.content || '{}'; const componentTree = JSON.parse(content) as ComponentNode; return { success: true, componentTree }; } catch (error) { console.error('Error generating UI:', error); return { success: false, error: 'Failed to generate UI' }; } }
生成するNodeツリーの定義
// 利用可能なMUIコンポーネントの型定義 export const AVAILABLE_COMPONENTS = { // レイアウト Box: 'Box', Stack: 'Stack', Paper: 'Paper', Grid: 'Grid', // テキスト Typography: 'Typography', // ボタン・アクション Button: 'Button', IconButton: 'IconButton', Fab: 'Fab', // フォーム TextField: 'TextField', FormControl: 'FormControl', InputLabel: 'InputLabel', Select: 'Select', MenuItem: 'MenuItem', Checkbox: 'Checkbox', Radio: 'Radio', RadioGroup: 'RadioGroup', FormControlLabel: 'FormControlLabel', Switch: 'Switch', Slider: 'Slider', Rating: 'Rating', // テーブル Table: 'Table', TableBody: 'TableBody', TableCell: 'TableCell', TableContainer: 'TableContainer', TableHead: 'TableHead', TableRow: 'TableRow', // カード Card: 'Card', CardContent: 'CardContent', CardActions: 'CardActions', // リスト List: 'List', ListItem: 'ListItem', ListItemText: 'ListItemText', // その他 Chip: 'Chip', Divider: 'Divider', LinearProgress: 'LinearProgress', CircularProgress: 'CircularProgress', Alert: 'Alert', // アコーディオン Accordion: 'Accordion', AccordionSummary: 'AccordionSummary', AccordionDetails: 'AccordionDetails', // タブ Tabs: 'Tabs', Tab: 'Tab', } as const; export type AvailableComponentType = keyof typeof AVAILABLE_COMPONENTS; // コンポーネントノードの型定義 export type ComponentNode = { type: AvailableComponentType; props?: Record<string, unknown>; children?: (ComponentNode | string)[]; }; // テーブル構造の型定義 // デフォルトのテーブルテンプレート export const DEFAULT_TABLE_TEMPLATE = { type: 'Table', props: { sx: { bgcolor: 'white', width: '100%', }, }, children: [ { type: 'TableHead', children: [{ type: 'TableRow', props: { sx: { '& th': { px: 2, py: 1, whiteSpace: 'nowrap', }, }, }, children: [ { type: 'TableCell', children: ['ラベル検査名'], }, { type: 'TableCell', children: ['ラベル管理番号'], }, { type: 'TableCell', children: ['状態'], }, ], }], }, { type: 'TableBody', children: [{ type: 'TableRow', props: { tabIndex: 0, sx: { cursor: 'pointer', '&:hover': { bgcolor: '#f5f5f5' }, '&:focus': { bgcolor: '#f5f5f5' }, '& > td': { py: 2, minWidth: 180, }, }, }, children: [ { type: 'TableCell', children: [{ type: 'Box', props: { sx: { display: 'inline-flex', alignItems: 'center', gap: '4px', }, }, children: [ '{name}', { type: 'IconButton', props: { size: 'small', sx: { p: 0, ml: 0.5, }, tabIndex: -1, }, children: [], }, ], }], }, { type: 'TableCell', children: ['{code}'], }, { type: 'TableCell', children: [{ type: 'Stack', props: { direction: 'row', alignItems: 'center', }, children: [ { type: 'Box', props: { sx: { width: 10, height: 10, bgcolor: 'success.main', borderRadius: '50%', marginRight: 1, }, }, }, '{status, 表示 | 非表示}', ], }], }, ], }], }, ], } as const; // コンポーネント使用ガイドライン export const COMPONENT_GUIDELINES = { // テーブル構造のガイドライン Table: { description: 'データテーブルの表示に使用', requiredChildren: ['TableHead', 'TableBody'], structure: 'Table > [TableHead, TableBody] > TableRow > TableCell', notes: 'propsの中にchildrenを含めない。childrenは別プロパティとして定義する。', }, Box: { description: 'フレキシブルなレイアウトコンテナ', commonProps: ['sx', 'component'], notes: 'display, alignItems, justifyContent等のフレックスボックス属性を活用', }, Stack: { description: '要素を縦横に並べるコンテナ', commonProps: ['direction', 'spacing', 'alignItems', 'justifyContent'], notes: 'direction="row"で横並び、direction="column"で縦並び', }, // テキストコンポーネント Typography: { description: 'テキスト表示用コンポーネント', commonProps: ['variant', 'component', 'sx'], variants: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body1', 'body2', 'caption'], }, } as const;
チャット側
import { create } from 'zustand'; import type { ComponentNode } from './componentProtocol'; type GeneratedUI = { id: string; componentTree: ComponentNode; }; type ChatState = { isOpen: boolean; generatedUI: GeneratedUI | null; toggleChat: () => void; setGeneratedUI: (ui: GeneratedUI | null) => void; clearGeneratedUI: () => void; }; export const useChatStore = create<ChatState>((set) => ({ isOpen: false, generatedUI: null, toggleChat: () => { set((state) => ({ isOpen: !state.isOpen })); }, setGeneratedUI: (ui: GeneratedUI | null) => { set({ generatedUI: ui }); }, clearGeneratedUI: () => { set({ generatedUI: null }); }, }));
import { useState } from 'react'; import { useChatStore } from './chatStore'; import { generateUI } from './actions'; export const ChatDialog = () => { const { setGeneratedUI } = useChatStore(); const [input, setInput] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const userInput = input.trim(); setInput(''); const result = await generateUI(userInput); if (result.success === false) { alert(`エラー: ${result.error}`); return; } setGeneratedUI({ id: crypto.randomUUID(), componentTree: result.componentTree, }); }; return ( {// Dialog部分は省略 } <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', width: '100%', gap: 1 }} > <TextField fullWidth size="small" value={input} onChange={(e) => setInput(e.target.value)} placeholder="メッセージを入力..." disabled={isLoading} /> <Button type="submit" variant="contained" disabled={!input.trim()} sx={{ minWidth: 'auto', px: 2 }} > <SendIcon /> </Button> </Box> ); };
UIの描画
'use client'; import { Box } from '@mui/material'; import { ComponentRenderer } from './ComponentRenderer'; import { useChatStore } from './chatStore'; export function SafeGeneratedUI() { const { generatedUI } = useChatStore(); if (!generatedUI) return null return ( <Box sx={{ width: '100%', height: '100%', backgroundColor: 'background.paper', }} > <ComponentRenderer componentTree={generatedUI.componentTree} /> </Box> ); }
'use client'; import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Card, CardActions, CardContent, Checkbox, Chip, CircularProgress, Divider, Fab, FormControl, FormControlLabel, Grid, IconButton, InputLabel, LinearProgress, List, ListItem, ListItemText, MenuItem, Paper, Radio, RadioGroup, Rating, Select, Slider, Stack, Switch, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, TextField, Typography, } from '@mui/material'; import React from 'react'; import type { ComponentNode } from './componentProtocol'; const componentMap = { Box, Typography, Button, Stack, Paper, Card, CardContent, CardActions, Chip, List, ListItem, ListItemText, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Grid, IconButton, Fab, TextField, FormControl, InputLabel, Select, MenuItem, Checkbox, Radio, RadioGroup, FormControlLabel, Switch, Slider, Rating, LinearProgress, CircularProgress, Divider, Alert, Accordion, AccordionSummary, AccordionDetails, Tabs, Tab, } as const; type Props = { componentTree: ComponentNode }; export function ComponentRenderer({ componentTree }: Props) { const render = (node: ComponentNode | string, index: number): React.ReactNode => { if (typeof node === 'string') return node; const Component = componentMap[node.type]; const props = { ...node.props, key: index }; const children = node.children?.map(render) || []; return React.createElement(Component as React.ComponentType<Record<string, unknown>>, props, ...children); }; return <Box sx={{ width: '100%', height: '100%', p: 2 }}>{render(componentTree, 0)}</Box>; }
実装まとめ
あまり本質的でない部分は省略しています。
重要な点
生成UIの実装で重要になる点は
- LLMとどのようなプロトコルでやり取りするか
- プロンプトの設計
- 生成物をどのようにHTML(やReactコンポーネント)に変換するか
- 生成物に対するセキュリティ観点のバリデーション
この辺りかなと思いました。
今回は、
- LLMとどのようなプロトコルでやり取りするか
- NodeツリーをJSON形式でやり取りするようにした。
- プロンプトの設計
- デフォルトのNodeツリーを与えていないと、毎回ランダムなUIが生成されてしまい、ガチャガチャになる。
- ↑を与えているとそれをベースに考えてくれるようになるので、解の範囲を狭くしてあげられる。
- そのぶんクリエイティブさは失われる。
- 生成物をどのようにHTML(やReactコンポーネント)に変換するか
- 簡易的にrendererを実装。
- 本当にやるならもっと漏れなくやる必要あり。
- 生成物に対するセキュリティ観点のバリデーション
- だいぶ省略している。onClickなどのEventに危険なスクリプトを埋め込まれないようにバリデーションをしてあげる必要あり。
展望
Nodeツリーの前に抽象レイヤーを一つ挟む。 より抽象化されたUIオブジェクトを生成してもらい、どのようなUIライブラリにでも対応するような実装もあるでしょう。 今回は、できる限りLLMにUIの詳細までを出力してもらいたかったので、MUIのNodeツリーを直接生成してもらう形にしました。
テンプレートエンジンの実装。 本来はLLMにマスタデータを直接渡さずに、テンプレートエンジンを用意してクライアント側でマスタデータを埋め込む形にした方が良いでしょう。LLMのトークンの解釈次第で実データを捻じ曲げて埋め込まれる可能性があったり、不必要にトークン数を消費してしまいます。
さらなるデザインクオリティを引き出したい。 管理画面なのでシンプルなのは良いですが、カード型UIなどちょっと微妙なスタイリングに思えました。 例えば「ハリウッド映画のようなド派手なUI」など、よりクリエイティブな要求に対しても対応できるようにしたいです。 モデルやプロンプトによるところもあると思うので、今後はGeminiやClaudeなどでも検証してみたいですね。
エピローグ
「生成UIで世界を変え隊なぁ」の諸君、生成UIの可能性を感じ、その大きな大きな一歩を踏み出したかい?
さぁ、数年後生成UIが当たり前の世界で笑うのは私たちだ!
がっはっはっは!!
劇場版「ジェ。」- 生成AIとUIの関係について- エピソード6: デザイナーの帰還 へ続く...