本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ローディング

チャットの返信を待つローディングの点が波打ち、そのまま届いたメッセージの吹き出しに広がる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

返信を待つあいだ、相手の側に小さな吹き出しを出し、中の 3 つの点を順に波打たせます。返信が届いたら点を消し、同じ吹き出しの幅と高さを本文の大きさまで広げて、中から文を出します。別の吹き出しに差し替えないので、「入力中」から返信まで、目線を動かさずに読めます。

点は 0.25 秒で浮いて 0.25 秒で沈み、隣の点とは 0.14 秒ずつずらして、1 秒で一巡させます。浮く高さは 6px で、浮いている点だけを濃くします。広げるのは 0.5 秒の power3.inOut で、文は広がり始めて 0.15 秒後から 0.3 秒かけて出します。文を少し遅らせると、狭い枠の中に文が詰まって見える瞬間を避けられます。

広げる前と後の大きさは、本文を入れる前と入れた後に測って補間します。広がる途中で本文の幅が変わると、行の折り返しが毎コマ変わって文が跳ねるので、測った幅を本文の width に固定し、広がりきったら外します。点の往復(yoyo と repeat)は、stagger の中に書きます。外に書くと、3 つの点がまとめて動きます。

吹き出しは下にそろえて積み、新しい吹き出しは、下の余白を「高さ + 間」だけ負にした位置から 0 に戻して出します。上の吹き出しは、そのぶん滑らかに押し上げられます。下へ寄せるのは、先頭の吹き出しの上の余白を auto にする形にします。justify-content で下へ寄せると、あふれたときに上へ戻れなくなります。

返信を受け取る関数を、実際のチャットの API に差し替えて使います。受け取れなかったときも同じ吹き出しを広げ、その旨を文で知らせます。読み上げには、点の代わりに「入力中」の文を届け、届いた返信に入れ替えます。動きを減らす設定では点を波打たせず、広げる動きもほぼ一瞬にします。サポートのチャット、AI の回答、問い合わせの自動応答に向いています。

コード

HTML
<section class="chat" aria-label="チャットで相談">
  <!-- role="log" は、足した吹き出しを読み上げに順に届ける -->
  <div class="chat__log" role="log">
    <p class="chat__message">こんにちは、担当の佐藤です。<br>制作のご相談を承っています。</p>
  </div>
  <form class="chat__form">
    <label class="chat__label" for="chat-message">メッセージ</label>
    <input class="chat__input" id="chat-message" name="message" type="text" placeholder="メッセージを入力" autocomplete="off" required />
    <button class="chat__send" type="submit" aria-label="送る">
      <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
    </button>
  </form>
</section>

CSS
.chat {
  --ground: #111111;                /* 地・自分の吹き出しの文字 */
  --surface: #262626;               /* 相手の吹き出し */
  --ink: #eeece6;                   /* 文字・自分の吹き出し */
  --mute: #a8a6a0;                  /* 入力の案内 */
  --line: rgba(238, 236, 230, 0.14);
  display: grid;
  grid-template-rows: 1fr auto;
  width: min(100%, 480px);
  height: 520px;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--line);
  background: var(--ground);
  color: var(--ink);
  font-size: 15px;
  letter-spacing: 0.04em;
}

/* 吹き出しは下にそろえて積む(先頭の margin-top: auto。justify-content: flex-end にすると上へ戻れなくなる) */
.chat__log {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
  padding: 20px 24px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.chat__log > :first-child { margin-top: auto; }

.chat__message {
  position: relative;
  align-self: flex-start;
  max-width: 80%;
  margin: 0;
  padding: 12px 18px;
  overflow: hidden; /* 広がる途中の本文を切る */
  border-radius: 20px 20px 20px 6px;
  background: var(--surface);
  line-height: 1.6;
  word-break: auto-phrase; /* 和文を文節で折り返す */
}

.chat__message--me {
  align-self: flex-end;
  border-radius: 20px 20px 6px 20px;
  background: var(--ink);
  color: var(--ground);
}

/* 入力中の点。返信が届いたら浮かせて(本文の場所を空けて)消す */
.chat__dots { display: flex; gap: 6px; padding: 7px 2px; }
.chat__message.is-replied .chat__dots { position: absolute; left: 18px; bottom: 12px; }
.chat__dots i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: 0.35; }
.chat__body { display: block; white-space: pre-line; } /* 返信の改行(\n)をそのまま見せる */

/* 入力中は、本文(読み上げ用の「入力中」)を画面から隠す */
.chat__message.is-typing .chat__body,
.chat__label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.chat__form { display: flex; gap: 12px; padding: 16px 24px; box-shadow: inset 0 1px 0 var(--line); }

.chat__input {
  flex: 1;
  min-width: 0;
  height: 48px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line);
  background: transparent;
  color: inherit;
  font: inherit;
}

.chat__input::placeholder { color: var(--mute); }
.chat__input:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--ink); }

.chat__send {
  display: grid;
  flex: none;
  place-items: center;
  width: 48px;
  height: 48px;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  cursor: pointer;
}

.chat__send svg { fill: none; stroke: var(--ground); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.chat__send:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で動かす

// 返信を受け取る所。案件ではチャットの API に差し替える(この例は 1.8 秒待って、決まった文を返す)
const fetchReply = () =>
  new Promise((resolve) => setTimeout(() => resolve("サイトの規模によりますが、\n1〜2か月ほどいただいています。"), 1800));

document.querySelectorAll(".chat").forEach((chat) => {
  const log = chat.querySelector(".chat__log");
  const form = chat.querySelector(".chat__form");
  const input = form.elements.message;
  const stick = () => (log.scrollTop = log.scrollHeight); // いちばん下を見せ続ける

  const add = (mine) => {
    const message = document.createElement("p");
    message.className = mine ? "chat__message chat__message--me" : "chat__message";
    log.append(message);
    return message;
  };

  // 下の余白を「高さ + 間」だけ負にして下へ隠し、0 に戻して押し上げる(上の吹き出しも一緒に上がる)
  const enter = (message, delay = 0) => {
    const gap = parseFloat(getComputedStyle(log).rowGap) || 0;
    return gsap
      .fromTo(
        message,
        { marginBottom: -(message.offsetHeight + gap), opacity: 0 },
        { marginBottom: 0, opacity: 1, duration: 0.4, delay, ease: "power3.out", clearProps: "marginBottom,opacity", onUpdate: stick }
      )
      .timeScale(SPEED);
  };

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const text = input.value.trim();
    if (!text) return;
    input.value = "";
    const mine = add(true);
    mine.textContent = text;
    enter(mine);

    // 入力中の吹き出し。本文は読み上げ用の「入力中」で、画面には点だけを見せる
    const bubble = add(false);
    bubble.classList.add("is-typing");
    bubble.innerHTML = '<span class="chat__dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="chat__body">入力中</span>';
    const dots = bubble.querySelectorAll(".chat__dots i");
    const body = bubble.querySelector(".chat__body");
    enter(bubble, 0.6);

    // 点を順に波打たせ、1 秒で一巡させる(yoyo と repeat は stagger の中に書く。外に書くと 3 つがまとめて動く)
    const wave = reduceMotion
      ? null
      : gsap
          .timeline({ repeat: -1 })
          .to(dots, { y: -6, opacity: 1, duration: 0.25, ease: "sine.inOut", stagger: { each: 0.14, yoyo: true, repeat: 1 } })
          .set({}, {}, 1);

    const reply = await fetchReply(text).catch(() => "返信を受け取れませんでした。時間をおいてお試しください。");
    wave?.kill();

    // 同じ吹き出しを、本文の大きさへ広げる。広げる前と後の大きさを測ってから補間する
    const from = { width: bubble.offsetWidth, height: bubble.offsetHeight };
    body.textContent = reply;
    bubble.classList.replace("is-typing", "is-replied");
    const to = { width: bubble.offsetWidth, height: bubble.offsetHeight };
    gsap.set(body, { width: body.offsetWidth }); // 広がる途中で文が折り返し直さないよう、幅を決めておく

    gsap
      .timeline({
        onUpdate: stick,
        onComplete: () => {
          bubble.querySelector(".chat__dots").remove();
          gsap.set(bubble, { clearProps: "width,height" });
          gsap.set(body, { clearProps: "width,opacity,transform" });
        },
      })
      .to(dots, { opacity: 0, duration: 0.12 }, 0)
      .fromTo(bubble, from, { ...to, duration: 0.5, ease: "power3.inOut" }, 0)
      .fromTo(body, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: "power1.out" }, 0.15)
      .timeScale(SPEED);
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。