Next.jsチャットUIでアバターの状態(待機・思考中・発話中)を切り替える実装
はじめに
AIチャットアシスタントを開発する中で、AIが「今何をしているか」をユーザーに伝えるUXが重要だと感じました。テキストだけのステータス表示では味気ないので、アバターのアニメーションを状態に応じて切り替えることにしました。
本記事では、Next.jsのチャットUIでアバターGIFを「待機中(idling)」「思考中(thinking)」「発話中(speaking)」の3状態で切り替える実装を紹介します。

前提・環境
- Next.js 15(App Router)
- React 19
- TypeScript
- Tailwind CSS v4
完成イメージ
アバターは3つの状態を持ちます。
| 状態 | 見た目 | 使用場面 |
|---|---|---|
| idling(待機中) | 正面を向いて微笑んでいる | ユーザーの入力待ち、回答表示後 |
| thinking(思考中) | 顎に手を当てて考えている | バックエンド処理中(検索・生成中) |
| speaking(発話中) | 手を上げて話している | 回答を読み上げている |
ユーザーが質問を送信すると、アバターが「待機中 → 思考中 → 待機中」と遷移し、処理の進行が視覚的に伝わります。
実装
1. アバターGIFの準備
まず、3つの状態に対応するアニメーションGIFを用意して public/avatars/ に配置します。
public/
avatars/
maya-idling.gif # 待機中:正面を向いて微笑む
maya-thinking.gif # 思考中:顎に手を当てて考える
maya-speaking.gif # 発話中:手を上げて説明する
GIFアニメーションは微妙な動き(まばたき、呼吸など)を含むループアニメーションにすると自然に見えます。
2. アバターコンポーネント
3つの状態に対応するGIFパスをマッピングし、state propsで切り替えるコンポーネントを作ります。
// components/MayaAvatar.tsx
"use client";
import { useEffect } from "react";
import Image from "next/image";
type AvatarState = "idling" | "speaking" | "thinking";
const avatarSrc: Record<AvatarState, string> = {
idling: "/avatars/maya-idling.gif",
speaking: "/avatars/maya-speaking.gif",
thinking: "/avatars/maya-thinking.gif",
};
interface MayaAvatarProps {
state: AvatarState;
size?: number;
}
export function MayaAvatar({ state, size = 40 }: MayaAvatarProps) {
// マウント時に全GIFをプリロードして状態切り替え時のちらつきを防ぐ
useEffect(() => {
Object.values(avatarSrc).forEach((src) => {
const img = new window.Image();
img.src = src;
});
}, []);
return (
<div
className="shrink-0 overflow-hidden rounded-full bg-white ring-1 ring-gray-200"
style={{ width: size, height: size }}
>
<Image
src={avatarSrc[state]}
alt={`Maya ${state}`}
width={size}
height={size}
unoptimized
priority
className="h-full w-full object-cover"
/>
</div>
);
}
ポイント: GIFプリロード
最も重要な実装ポイントは、コンポーネントのマウント時に全状態のGIFを事前読み込みしている部分です。
useEffect(() => {
Object.values(avatarSrc).forEach((src) => {
const img = new window.Image();
img.src = src;
});
}, []);
これがないと、状態が idling → thinking に切り替わった瞬間にGIFのダウンロードが始まり、一瞬白い画像やちらつきが発生します。new Image() で事前にブラウザキャッシュに載せておくことで、状態切り替えが即座に反映されます。
ポイント: unoptimized プロパティ
Next.jsの Image コンポーネントはデフォルトで画像を最適化(WebP変換・リサイズ)しますが、GIFアニメーションに対してこの最適化が適用されるとアニメーションが壊れます。unoptimized を指定してGIFをそのまま配信します。
3. チャットの状態型定義
チャットの各バブル(吹き出し)を型で管理します。アバターの状態に直接関わるのは progress と progress-done です。
// lib/chat.ts
export type ProgressStep = {
id: "analyzing" | "retrieving" | "fetching" | "generating";
label: string;
status: "pending" | "active" | "done";
};
export type ChatBubble =
| { type: "user"; text: string }
| { type: "progress"; steps: ProgressStep[]; collapsed: boolean; queryId: string }
| { type: "progress-done"; steps: ProgressStep[]; collapsed: boolean; queryId: string }
| { type: "results"; results: SearchResult[]; general_advice: string } // SearchResult はアプリ固有の型
| { type: "streaming"; general_advice: string }
| { type: "clarification"; question: string; options?: string[] }
| { type: "error"; text: string };
collapsed プロパティがアバター状態を決めるキーです。
collapsed: false→ 処理進行中 → アバターは thinkingcollapsed: true→ 処理完了 → アバターは idling
4. チャットスレッドでのアバター表示
チャットスレッドのレンダリング部分で、バブルの種類に応じてアバターの状態を切り替えます。
// components/ChatThread.tsx(抜粋)
// 処理中 / 処理完了のバブル
if (bubble.type === "progress" || bubble.type === "progress-done") {
return (
<div className="animate-fade-in-up flex items-start gap-3">
<MayaAvatar state={bubble.collapsed ? "idling" : "thinking"} size={40} />
<div className="w-full max-w-[calc(95%-52px)]">
<ProgressTimeline bubble={bubble} />
</div>
</div>
);
}
// 回答結果のバブル
if (bubble.type === "results") {
return (
<div className="animate-fade-in-up flex items-start gap-3">
<MayaAvatar state="idling" size={40} />
{/* 回答コンテンツ */}
</div>
);
}
// エラーのバブル
if (bubble.type === "error") {
return (
<div className="animate-fade-in-up flex items-start gap-3">
<MayaAvatar state="idling" size={40} />
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{bubble.text}
</div>
</div>
);
}
核心は1行です:
<MayaAvatar state={bubble.collapsed ? "idling" : "thinking"} size={40} />
collapsed のboolean値で状態を三項演算子で切り替えるだけ。シンプルです。
5. 状態遷移の流れ
ユーザーがメッセージを送信してから回答が返るまでの、アバター状態遷移を図にすると以下のようになります。

SSEイベントによる状態遷移の実装はこうなっています。
// app/page.tsx(抜粋)
// 1. ユーザー送信時:progressバブルを collapsed: false で生成
setBubbles((prev) => [
...prev,
{ type: "user", text },
{ type: "progress", steps: [], collapsed: false, queryId },
]);
// 2. SSE progressイベント受信時:ステップを追加(thinkingのまま)
if (eventType === "progress") {
setBubbles((prev) =>
prev.map((b) => {
if (b.type !== "progress" || b.queryId !== queryId) return b;
const updated = b.steps.map((s) =>
s.status === "active" ? { ...s, status: "done" as const } : s,
);
return { ...b, steps: [...updated, makeStep(newStepId)] }; // makeStep: ステップ生成ヘルパー(省略)
}),
);
}
// 3. 結果受信時:collapsed: true に変更 → アバターがidlingに戻る
setBubbles((prev) => {
return prev.map((b) => {
if (b.type !== "progress" || b.queryId !== queryId) return b;
return {
...b,
type: "progress-done" as const,
collapsed: true,
steps: b.steps.map((s) =>
s.status === "active" ? { ...s, status: "done" as const } : s,
),
};
});
});
6. ランディングページでのアバター
チャット未開始時のランディングページでは、大きなサイズで待機中のアバターを表示しています。
// app/page.tsx(抜粋)
{bubbles.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-20">
<MayaAvatar state="idling" size={96} />
<p className="text-lg font-semibold text-gray-800">こんにちは!</p>
<p className="text-sm text-gray-500">お気軽にお問い合わせください。</p>
</div>
) : (
<ChatThread bubbles={bubbles} ... />
)}
設計判断のポイント
なぜGIFなのか
Lottie(JSON)やCSSアニメーションも検討しましたが、以下の理由でGIFを選びました。
- デザイナーとの分業: デザイナーがGIFを書き出すだけで完結し、フロントエンドの実装変更が不要
- シンプルさ:
<img src={...}>だけで動く。ライブラリ依存なし - ファイルサイズ: 各GIFは2〜3MB程度。プリロードすればUX上の問題なし
なぜ collapsed プロパティで管理するのか
アバター用に別途 avatarState のようなstateを持つことも考えましたが、progressバブルの collapsed に乗せることで:
- 状態の二重管理を避けられる
- バブルのライフサイクルとアバター状態が必ず同期する
- 新しいstateやuseEffectの追加が不要
まとめ
アバターの状態切り替えの実装ポイントをまとめます。
- 3つの状態を型で定義して、GIFパスのマッピングを持つシンプルなコンポーネントを作る
- マウント時に全GIFをプリロードして、状態切り替え時のちらつきを防ぐ
- 既存のバブル状態(
collapsed)をそのまま利用して、アバター状態を導出する - Next.jsの
ImageコンポーネントでGIFを使う際はunoptimizedを忘れずに指定する
余分なstateを増やさず、既存のデータ構造から状態を導出するアプローチは、他のUI要素の状態管理にも応用できると思います。



