デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- 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 の回答、問い合わせの自動応答に向いています。
コード
<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>
.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; }
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);
});
});