実際のユーザーに耐える AI チャット UI を shadcn/ui と Vercel AI SDK で作る方法
ストリーミングする AI チャットは、デモは簡単でも出荷は難しい。停止と再試行からツールの承認、CJK 入力まで、shadcn/ui と AI SDK のチャットが扱うべき状態。
Preet Suthar · · 6分で読めます
- AI
- React
- shadcn/ui
- Next.js
デモは半日でできます。AI SDK の useChat、メッセージのリスト、textarea、そしてトークンが画面にストリーミングされます。チームの全員が感心します。
そして実際の人が使います。日本語を変換中に Enter を押して、単語の途中で送信してしまいます。返信を停止すると UI には何も表示されません。ストリームの途中でツール呼び出しが承認を求めても、そのための UI がありません。読み返そうと上にスクロールすると、トークンが届くたびに最下部へ引き戻されます。
この画面は望んだ以上の回数作ってきました。直近では HextaUI のブロックとしてです。これはチャット UI が完成と言える前に対応しておくべきことのチェックリストで、それぞれに shadcn/ui と AI SDK 固有の話を添えています。
文字列ではなくメッセージパーツから始める
AI SDK の useChat はパーツで構成されたメッセージを返します。テキスト、推論、状態付きのツール呼び出し、ソース、ファイルです。連結した文字列ではなく、パーツを描画してください。以下の機能はすべてこれに依存しています。
ツールパーツの型は tool-<name> なので、isToolUIPart が確実な判定方法です。
ブロックには SDK の import を入れないでください。パーツを props として受け取ります。そうすれば、出力を同じ形に変換するだけで、同じコンポーネントが LangGraph のバックエンドでも自前のサーバーでも動きます。
コンポーザー
バグの大半は textarea に住んでいます。
Enter で送信、Shift+Enter で改行。 誰もがこうします。しかし変換中の処理をしている人はほとんどいません。日本語、中国語、韓国語を入力しているときは IME が開いていて、Enter はメッセージではなく変換候補を確定します。event.nativeEvent.isComposing を確認して抜けてください:
テキストに合わせて、上限つきで伸ばす。 最初は一行、八行程度まで伸び、その先はスクロールします。高さの変化はアニメーションさせますが、キー入力のたびに React の state を経由してはいけません。レイアウトエフェクトで scrollHeight を測り、DOM ノードに高さを直接設定します。ネイティブの input リスナー内の setState は、負荷がかかるといずれキー入力を落としたり遅らせたりします。
下書きはリロードしても残す。 下書きはチャット id をキーにして、デバウンスしつつ localStorage に保存します。うっかりリフレッシュして長いプロンプトを失った人は、二度と許してくれません。
返信中に入力されたメッセージはキューに入れる。 モデルがまだ書いている最中にユーザーが別のメッセージを送ったら、送信を無効にするか、キューに入れてストリーム終了後に送ります。黙って捨てるのは最悪の選択肢で、しかもそれがデフォルトです。
添付ファイルには進捗と再試行を表示する。 アップロードは失敗します。失敗は消えてしまうトーストではなく、添付ファイルのカード上に再試行ボタン付きで表示してください。
読みやすいストリーミング
モデルはトークンをまとめて吐き出します。届いたものをそのまま描画すると、テキストはカクつきます。何もなし、次に 12 語、また何もなし、という具合です。なめらかにしましょう。バッファが増えたら速くなる一定のペースで文字を表示すれば、ストリームから大きく遅れることも、カクつくこともありません。
ストリーミング中の Markdown には固有の罠があります。書きかけの構文です。モデルが **bold まで書いてまだ閉じていないと、レンダラーは一瞬アスタリスクをそのまま表示し、次に太字を表示します。未完了のインライン構文は、閉じるかストリームが終わるまで表示を保留してください。
コードブロックにも同じ配慮が必要です。ハイライトは差分で行い、トークンごとにブロック全体をハイライトし直してはいけません。
停止、再試行、編集、バージョン
停止したら途中までの返信を画面に残し、未完了だとわかるように「Stopped」のマーカーを見えるように付けます。削除してはいけません。
失敗した返信の再試行は、隠れたジェスチャーではなく、メッセージ上のボタンにします。regenerate() につなぎます。
以前のメッセージを編集するとブランチができます。古い返信と新しい返信の両方が存在します。メッセージ上に小さな 1 / 2 の切り替えを表示して、ユーザーがバージョンを行き来できるようにします。自前のチャット UI の多くはここで諦めますし、ChatGPT や Claude から来た人が最も恋しがる機能でもあります。
失敗した返信には理由と再試行を付けます。アクションのない「問題が発生しました」は行き止まりです。
スクロール
ユーザーが最下部にいるときは新しいトークンに追従します。上にスクロールした瞬間に追従をやめ、見逃した件数付きの「最新へ移動」ボタンを表示します。移動したら再び追従します。
上に古い履歴を読み込むときに、表示位置を動かしてはいけません。先頭に追加する前後でスクロール高さを測り、同じフレーム内でその差分だけ scrollTop を調整します。
ユーザーがメッセージを送ったら、そのメッセージがビューポートの上端に固定され、返信がその下にストリーミングされるようにスクロールします。二大チャットアプリはいまそう振る舞っていて、ユーザーもそれを期待しています。
ツール呼び出しと承認
AI SDK は各ツールパーツに状態を与えます。input-streaming、input-available、output-available、output-error、そして human-in-the-loop では approval-requested、approval-responded、output-denied です。すべての状態に見た目が必要です。
読み取りと検索は一行の要約に畳んで構いません。編集、コマンド、エラーは展開したままにします。承認はストリームをブロックするので、明確な Allow と Deny に加えて、「代わりにこうして」と伝える手段が必要です。そのテキストは拒否理由としてモデルに返します。
結果は役に立つときに見せます。ファイル編集は diff として開き、コマンドは出力付きのターミナルとして開き、検索は結果として開きます。「Done」としか言わないツール呼び出しは、ユーザーにツール呼び出しを無視することを教えます。
思考とソース
推論パーツは折りたためるようにし、回答が始まったらデフォルトで折りたたみます。モデルがまだ考えている間は、各ステップに追従するライブなラベル(「ドキュメントを検索中」「3 つのファイルを読んでいます」)のほうが、スピナーよりずっと安心感があります。
引用は、誰も読まない末尾のリストではなく、裏付ける主張のすぐ隣にインラインで置きます。
省略できないアクセシビリティ
- 新しいメッセージは
role="log"のリージョンを通して通知し、スクリーンリーダーのユーザーにも返信が聞こえるようにします。 - 停止ボタンはキーボードで到達でき、ラベルを持ちます。
- ストリーミング中のテキストが、ライブリージョンの読み上げを何百回も発火させてはいけません。メッセージの完了時か、文の区切りで読み上げます。
- アイコンだけのボタンにはすべて
aria-labelを付けます。
まとめると
部品が欲しいなら、shadcn/ui が 2026 年 10 月に AI Elements を公開しました。Chat、Prompt Input、Reasoning、Tool を含む 21 個のパターンで、プロジェクトにコピーして自分で組み合わせます。Vercel の以前の AI Elements ライブラリは、これらに置き換わる形で 2026 年 11 月末に非推奨になります。
上記の状態まで作り込まれた、組み立て済みの画面が欲しいなら、それが HextaUI のブロックです。停止、再試行、バージョンを備えた Chat Thread、AI SDK のすべての状態と承認に対応した Tool Calls、書きかけの構文を決して見せない Streaming と Markdown、そして CJK 入力、アップロード、キュー、下書きを扱う Prompt Input です。Prompt Input は無料です。残りは一回限りの購入です。すべて AI SDK のメッセージパーツを読みますが、SDK を import するものは一つもないので、変換できるバックエンドならどれでも動きます。
何もインストールする前に、チャットアプリとして組み上がった全体を hextaui.com/blocks/hexta-ai で試せます。
よくある質問
これらのパターンを使うには Vercel AI SDK が必要ですか? いいえ。役に立つのはメッセージパーツの形です。出力をパーツに変換できれば、どんなバックエンドでも動きます。
IME 使用中の Enter はどう扱いますか? keydown ハンドラーで event.nativeEvent.isComposing を確認し、早期に return します。
ストリーミング中のテキストはトークンごとにライブリージョンを更新すべきですか? いいえ。完了時か文の区切りで読み上げてください。さもないとスクリーンリーダーが使いものにならなくなります。
停止した返信はどうなりますか? 途中までのテキストを残し、停止済みと印を付けます。ユーザーがすでに読んだものを削除してはいけません。