Next.jsチャットUIでアバターの状態(待機・思考中・発話中)を切り替える実装

Next.jsチャットUIでアバターの状態(待機・思考中・発話中)を切り替える実装

Next.jsチャットUIでアバターGIFを「待機中」「思考中」「発話中」の3状態で切り替える実装を紹介。GIFプリロードによるちらつき防止、既存のバブル状態からアバター状態を導出するシンプルな設計がポイント。
2026.07.12

はじめに

AIチャットアシスタントを開発する中で、AIが「今何をしているか」をユーザーに伝えるUXが重要だと感じました。テキストだけのステータス表示では味気ないので、アバターのアニメーションを状態に応じて切り替えることにしました。

本記事では、Next.jsのチャットUIでアバターGIFを「待機中(idling)」「思考中(thinking)」「発話中(speaking)」の3状態で切り替える実装を紹介します。

avatar-for-different-states

前提・環境

  • 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;
  });
}, []);

これがないと、状態が idlingthinking に切り替わった瞬間にGIFのダウンロードが始まり、一瞬白い画像やちらつきが発生します。new Image() で事前にブラウザキャッシュに載せておくことで、状態切り替えが即座に反映されます。

ポイント: unoptimized プロパティ

Next.jsの Image コンポーネントはデフォルトで画像を最適化(WebP変換・リサイズ)しますが、GIFアニメーションに対してこの最適化が適用されるとアニメーションが壊れます。unoptimized を指定してGIFをそのまま配信します。

3. チャットの状態型定義

チャットの各バブル(吹き出し)を型で管理します。アバターの状態に直接関わるのは progressprogress-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 → 処理進行中 → アバターは thinking
  • collapsed: 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. 状態遷移の流れ

ユーザーがメッセージを送信してから回答が返るまでの、アバター状態遷移を図にすると以下のようになります。

nextjs-avatar-state-idle-thinking-speaking-state-flow

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要素の状態管理にも応用できると思います。

この記事をシェアする

関連記事