TECH · チャットUI
日本語で壊れない会話画面の組み方
チャットUIのサンプル集。連投をまとめる吹き出し、読み返し中は引き戻さない自動スクロール、変換確定で誤送信しない入力欄、止められて読み上げも乱れない流れて出る回答表示を、そのまま貼れるコードで収録します。
01 / SAMPLESサンプルとコード
同じ人の連続した発言は、アイコン・名前・角の尖りを先頭だけに付けます。判定は CSS の隣接セレクタだけで、JavaScript は使っていません。名前は消さずに画面外へ移し、読み上げでは話者が分かるようにしています。
表示領域より広い幅は、見本の枠内で横にスクロールできます。
HTML
<!-- 発言は時系列の並びなので ol で組む。読み上げで「リスト、6項目」と伝わる -->
<ol class="thread" aria-label="メッセージ">
<li class="msg them">
<span class="avatar" aria-hidden="true">み</span>
<span class="who">みさき</span>
<div class="line">
<p class="bubble">明日の打ち合わせ、資料は共有フォルダに入れておきました</p>
<time class="time" datetime="2026-09-24T21:14">21:14</time>
</div>
</li>
<li class="msg them">
<span class="avatar" aria-hidden="true">み</span>
<span class="who">みさき</span>
<div class="line">
<p class="bubble">https://example.com/share/project-2026/meeting-notes-final-version-v3</p>
<time class="time" datetime="2026-09-24T21:14">21:14</time>
</div>
</li>
<li class="msg me">
<span class="who">あなた</span>
<div class="line">
<p class="bubble">ありがとうございます!</p>
<time class="time" datetime="2026-09-24T21:20">21:20</time>
</div>
</li>
<li class="msg me">
<span class="who">あなた</span>
<div class="line">
<p class="bubble">先に目を通しておきます</p>
<time class="time" datetime="2026-09-24T21:20">21:20</time>
</div>
</li>
<li class="msg them">
<span class="avatar" aria-hidden="true" style="background:#5a8dd6">け</span>
<span class="who">けんた</span>
<div class="line">
<p class="bubble">僕は少し遅れて参加します</p>
<time class="time" datetime="2026-09-24T21:31">21:31</time>
</div>
</li>
</ol> CSS
.thread {
display: flex;
flex-direction: column;
gap: .25rem;
margin: 0;
padding: 1rem;
list-style: none;
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
}
.msg {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr);
column-gap: .5rem;
/* 話者が変わるところだけ間を空ける。同じ人の連投は詰めて1段落に見せる */
margin-top: .9rem;
}
.msg:first-child { margin-top: 0; }
.avatar {
grid-row: span 2;
width: 2rem; height: 2rem;
display: grid; place-items: center;
font-size: .75rem; font-weight: 700; color: #fff;
background: #e07a5f; border-radius: 50%;
}
.who { font-size: .75rem; color: var(--muted); line-height: 1.5; }
.line {
display: flex;
align-items: flex-end;
gap: .4rem;
min-width: 0;
}
.bubble {
/* 吹き出しは中身の幅だけ。長文でも75%で折り返し、残りを時刻の置き場にする */
max-width: 75%;
padding: .45rem .8rem;
line-height: 1.7;
background: #eef1f6;
border-radius: 4px 16px 16px 16px;
/* URL や英数字の長い連続は単語の途中でも折る。
break-word では「折れる場所が無い長い語」が吹き出しを突き破る */
overflow-wrap: anywhere;
}
.time { flex: none; font-size: .7rem; color: var(--muted); line-height: 1.4; }
/* ---- 自分の発言 ---- */
.msg.me { grid-template-columns: minmax(0, 1fr); }
.msg.me .line { flex-direction: row-reverse; }
.msg.me .bubble { color: #fff; background: var(--accent); border-radius: 16px 4px 16px 16px; }
/* ---- 連投のまとめ方(JavaScript を使わない) ----
直前が同じ話者なら、名前とアイコンを見た目の上で消して間を詰める。
名前は display:none にせず、画面外に残す。消すと読み上げでは
「誰の発言か」が分からなくなる */
.msg.them + .msg.them,
.msg.me + .msg.me { margin-top: 0; }
.msg.them + .msg.them .avatar { visibility: hidden; height: 0; }
.msg.them + .msg.them .who {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
/* 角の尖り(しっぽの代わり)も先頭の1通だけ */
.msg.them + .msg.them .bubble { border-radius: 16px; }
.msg.me + .msg.me .bubble { border-radius: 16px; }
/* 自分の発言の話者名は常に画面外。見た目では右寄せで自明だが、
読み上げでは位置が伝わらない */
.msg.me .who {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
} AIへの指示文
チャットの吹き出しを HTML と CSS だけで作ってください。JavaScript は使いません。
要件:
- 発言の並びは ol と li で組む。時系列の一覧であることが読み上げで伝わる、と理由を書く。
- 相手の発言は左にアイコンと名前、自分の発言は右寄せで色付きの吹き出し。
- 同じ話者が続いたら、2通目以降はアイコン・名前・角の尖りを消して間を詰める。
判定は隣接セレクタ(.msg.them + .msg.them)で行い、JavaScript を使わない。
- 消した名前は display:none にせず、画面外へ移す(visually hidden)。
消すと読み上げで誰の発言か分からなくなる、とコメントで書く。
- 自分の発言の話者名も画面外に置く。見た目では右寄せで自明でも、
読み上げでは位置が伝わらない、と書く。
- 吹き出しの最大幅は75%。時刻は吹き出しの外、内側の下端に time 要素で置く。
- 長いURLが吹き出しを突き破らないよう overflow-wrap: anywhere を指定し、
break-word では折れる場所の無い長い語がはみ出す、と理由を書く。
- 日本語の本文なので line-height は 1.7 以上、letter-spacing は 0。
最新を見ているときだけ追従し、上へ戻って読んでいるときは位置を動かさず「新着 N 件」を出します。位置は必ず追加する前に測ります。追加した後に測ると、増えた高さのぶん常に「離れている」と判定されてしまいます。
表示領域より広い幅は、見本の枠内で横にスクロールできます。
HTML
<div class="chat">
<!-- role="log" は「末尾に追記されていく領域」の役割。
既定で aria-live="polite" になり、追加分だけが読み上げられる -->
<div class="log" id="log" role="log" aria-label="メッセージ" tabindex="0"></div>
<button class="jump" id="jump" type="button" hidden>新着 <span id="unread">0</span> 件 ↓</button>
</div> CSS
.chat { position: relative; }
.log {
height: 320px;
overflow-y: auto;
padding: 1rem;
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
/* ブラウザの自動のスクロール固定(scroll anchoring)を切る。
下端に要素が足されるたびにブラウザが位置を補正すると、
こちらの「追従する/しない」の判定と喧嘩する */
overflow-anchor: none;
overscroll-behavior: contain;
}
.log p {
margin: 0 0 .5rem;
padding: .45rem .8rem;
width: fit-content;
max-width: 80%;
line-height: 1.7;
background: #eef1f6;
border-radius: 14px;
}
/* 読み返し中に新着が来たときだけ出す。押すと最下部へ戻る */
.jump {
position: absolute;
left: 50%;
bottom: .8rem;
translate: -50% 0;
padding: .35rem .9rem;
font: inherit; font-size: .82rem; font-weight: 600;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 999px;
box-shadow: 0 6px 16px -6px rgba(37, 99, 235, .6);
cursor: pointer;
}
.jump[hidden] { display: none; } JavaScript
const log = document.getElementById("log");
const jump = document.getElementById("jump");
const unreadEl = document.getElementById("unread");
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
// 下端からこの距離以内なら「最新を見ている」とみなす。
// 0 にすると、慣性スクロールや小数点の丸めで簡単に外れる
const FOLLOW_PX = 80;
let unread = 0;
const atBottom = () =>
log.scrollHeight - log.scrollTop - log.clientHeight <= FOLLOW_PX;
// 追従は瞬時に行う。smooth にすると、動いている途中に次の
// メッセージが届いたとき「下端から離れている」と判定されて追従が切れる。
// 滑らかに動かすのは、利用者が「新着」を押して戻るときだけにする
function toBottom(smooth = false) {
log.scrollTo({
top: log.scrollHeight,
behavior: smooth && !reduce.matches ? "smooth" : "auto",
});
}
// メッセージを足す前に位置を測る。足した後に測ると、
// 増えた高さのぶん必ず「下端から離れている」と判定されてしまう
function append(text, { mine = false } = {}) {
const follow = mine || atBottom();
const p = document.createElement("p");
p.textContent = text;
log.append(p);
if (follow) {
toBottom();
} else {
unread += 1;
unreadEl.textContent = unread;
jump.hidden = false;
}
}
// 利用者が自分で最下部まで戻ったら、新着の表示を消す
log.addEventListener("scroll", () => {
if (atBottom() && unread) {
unread = 0;
jump.hidden = true;
}
}, { passive: true });
jump.addEventListener("click", () => {
toBottom(true);
log.focus({ preventScroll: true });
}); AIへの指示文
新しいメッセージが届くチャットの表示領域を作ってください。
要件:
- 一番下を見ているときだけ、新着に合わせて自動でスクロールする。
- 上へ戻って読み返しているときは位置を動かさず、「新着 N 件 ↓」のボタンを出す。
押すと最下部へ滑らかに戻り、ボタンは消える。自分で最下部まで戻ったときも消す。
- 「一番下にいる」の判定は、下端から 80px 以内とする。
0px にすると慣性スクロールや丸め誤差で簡単に外れる、と理由を書く。
- 位置はメッセージを追加する前に測る。追加した後に測ると増えた高さのぶん
必ず離れていると判定される、とコメントで書く。
- 追従のスクロールは瞬時に行い、smooth にしない。動いている途中に次が届くと
追従が切れるため、と書く。滑らかにするのは新着ボタンで戻るときだけ。
- 領域に overflow-anchor: none を指定する。ブラウザの自動のスクロール固定と
自前の判定が喧嘩する、と理由を書く。
- 領域は role="log" にする。追記されていく領域の役割で、追加分だけが読み上げられる。
- prefers-reduced-motion では smooth を使わない。
日本語入力で変換を確定する Enter では送らず、確定後の Enter で送ります。Chrome と Firefox は isComposing、Safari は確定直後の keyCode 229 で見分けるので、両方を見ています。 展示用の補助処理で入力確認を再現し、送信・保存は行いません。補助処理はコピー対象に含めていません。
表示領域より広い幅は、見本の枠内で横にスクロールできます。
HTML
<form class="composer" id="composer">
<!-- ラベルは画面に出さないが、必ず付ける。placeholder はラベルの代わりにならない
(入力を始めると消え、読み上げでも扱いが一定しない) -->
<label for="msg" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">メッセージ</label>
<textarea id="msg" rows="1" maxlength="1000"
placeholder="メッセージを入力"
aria-describedby="msg-hint"
enterkeyhint="send"></textarea>
<button type="submit" id="send" aria-label="送信" disabled>↑</button>
</form>
<div class="meta">
<span id="msg-hint">Enter で送信 / Shift+Enter で改行</span>
<span class="count" id="count" aria-live="polite"></span>
</div> CSS
.composer {
display: flex;
align-items: flex-end;
gap: .5rem;
padding: .5rem .5rem .5rem .9rem;
background: #fff;
border: 1px solid var(--line);
border-radius: 20px;
}
.composer:focus-within { border-color: var(--accent); }
.composer textarea {
flex: 1;
min-width: 0;
padding: .25rem 0;
font: inherit;
line-height: 1.7;
color: inherit;
background: none;
border: 0;
outline: 0;
resize: none;
/* 中身に合わせて伸びる。対応ブラウザでは CSS だけで済み、
非対応ブラウザでは JavaScript が高さを入れる */
field-sizing: content;
min-height: 1.7lh;
/* 伸ばし続けると会話が押し出されて見えなくなる。上限を決めて中でスクロール */
max-height: 8lh;
}
.composer button {
flex: none;
width: 2.2rem; height: 2.2rem;
font-size: 1rem; line-height: 1;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 50%;
cursor: pointer;
}
.composer button:disabled { opacity: .3; cursor: default; }
.meta {
display: flex;
justify-content: space-between;
margin: .35rem .6rem 0;
font-size: .75rem;
color: var(--muted);
}
/* 残り字数は、上限に近づいたときだけ目立たせる */
.count[data-near] { color: var(--warn); font-weight: 600; } JavaScript
const form = document.getElementById("composer");
const input = document.getElementById("msg");
const send = document.getElementById("send");
const count = document.getElementById("count");
const MAX = Number(input.maxLength);
// 残りがこの字数を切ったら表示する。常に出すと、短い発言でも数字が目に入る
const NEAR = 100;
// field-sizing に対応していないブラウザだけ、高さを JavaScript で入れる
const autoSize = !CSS.supports("field-sizing", "content");
function resize() {
if (!autoSize) return;
input.style.height = "auto";
input.style.height = input.scrollHeight + "px";
}
function update() {
// 空白と改行だけの送信を防ぐ
send.disabled = input.value.trim() === "";
const left = MAX - input.value.length;
if (left <= NEAR) {
count.textContent = `残り ${left} 字`;
count.dataset.near = "";
} else {
count.textContent = "";
delete count.dataset.near;
}
resize();
}
input.addEventListener("input", update);
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey) return;
// 日本語入力の変換中・変換確定の Enter では送らない。
// isComposing は Chrome / Firefox の変換中、keyCode 229 は
// Safari の確定直後を拾う。片方だけだとどちらかで誤送信する
if (e.isComposing || e.keyCode === 229) return;
e.preventDefault();
form.requestSubmit();
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
form.dispatchEvent(new CustomEvent("chat:send", { detail: text }));
input.value = "";
update();
input.focus();
}); AIへの指示文
チャットのメッセージ入力欄を作ってください。日本語で使われます。
要件:
- textarea を使い、内容に合わせて高さが伸びる。上限は8行で、超えたら中でスクロールする。
対応ブラウザでは field-sizing: content を使い、非対応のときだけ JavaScript で高さを入れる。
- Enter で送信、Shift+Enter で改行。
- 日本語入力の変換中・変換確定の Enter では送信しない。
KeyboardEvent.isComposing が true のとき、または keyCode が 229 のときは無視する。
isComposing だけでは Safari の確定直後を取りこぼす、とコメントで書く。
- 空白や改行だけのときは送信ボタンを押せなくする。
- 残り字数は上限まで100字を切ったときだけ表示する。常に出すと短い発言でも数字が目に入る、と書く。
- ラベルは画面に出さなくても必ず付ける。placeholder はラベルの代わりにならない、と理由を書く。
- スマホのキーボードの確定キーを「送信」表示にするため enterkeyhint="send" を付ける。
生成中は回答に aria-busy を付けて読み上げを保留し、終わってから全文を一度だけ読ませます。停止は AbortController で行うので、実際の API では通信そのものを切れます。
表示領域より広い幅は、見本の枠内で横にスクロールできます。
HTML
<!-- role="log" は追記されていく領域。aria-live="polite" が既定で付く。
生成中は各回答の aria-busy="true" で読み上げを保留し、
終わってから全文を1回だけ読ませる -->
<div class="answer-log" id="answers" role="log" aria-label="回答"></div>
<div class="controls">
<button type="button" id="start">回答を生成</button>
<button type="button" id="stop" class="stop" disabled>止める</button>
</div> CSS
.answer-log {
min-height: 12rem;
padding: 1rem 1.2rem;
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
}
.answer { margin: 0 0 1rem; line-height: 1.85; white-space: pre-wrap; }
.answer:last-child { margin-bottom: 0; }
.stopped { color: var(--muted); font-size: .85rem; }
/* 生成中の目印。文字の後ろに置く四角いカーソル */
.answer[aria-busy="true"]::after {
content: "";
display: inline-block;
width: .55em; height: 1.05em;
margin-left: 2px;
vertical-align: -.15em;
background: var(--ink);
animation: caret 1s steps(1) infinite;
}
@keyframes caret { 50% { opacity: 0; } }
.controls { display: flex; gap: .5rem; margin-top: .8rem; }
.controls button {
padding: .45rem 1rem;
font: inherit; font-size: .88rem; font-weight: 600;
color: #fff; background: var(--accent);
border: 0; border-radius: 999px; cursor: pointer;
}
.controls button:disabled { opacity: .35; cursor: default; }
.controls .stop { background: var(--stop); }
@media (prefers-reduced-motion: reduce) {
.answer[aria-busy="true"]::after { animation: none; }
} JavaScript
const answers = document.getElementById("answers");
const startBtn = document.getElementById("start");
const stopBtn = document.getElementById("stop");
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
let controller = null;
// 実際の API の代わりに、文字を少しずつ返す。
// 本物では fetch のレスポンスの body.getReader() から同じ形で受け取る
async function* fakeStream(text, signal) {
for (let i = 0; i < text.length; i += 3) {
if (signal.aborted) return;
await new Promise((r) => setTimeout(r, 40));
yield text.slice(i, i + 3);
}
}
async function generate(text) {
controller = new AbortController();
const { signal } = controller;
const p = document.createElement("p");
p.className = "answer";
p.setAttribute("aria-busy", "true");
answers.append(p);
startBtn.disabled = true;
stopBtn.disabled = false;
// テキストノードに足していく。innerHTML に連結すると、
// 返ってきた文字列がタグとして解釈される(生成AIの出力は信用しない)
const node = document.createTextNode("");
p.append(node);
if (reduce.matches) {
// 動きを減らす設定では、ためてから一度に出す
let all = "";
for await (const chunk of fakeStream(text, signal)) all += chunk;
node.data = all;
} else {
for await (const chunk of fakeStream(text, signal)) node.data += chunk;
}
if (signal.aborted) {
const s = document.createElement("span");
s.className = "stopped";
s.textContent = "(ここで止めました)";
p.append(" ", s);
}
// ここで読み上げが解禁され、全文が1回だけ読まれる
p.setAttribute("aria-busy", "false");
startBtn.disabled = false;
stopBtn.disabled = true;
controller = null;
}
stopBtn.addEventListener("click", () => controller?.abort()); AIへの指示文
生成AIの回答が一文字ずつ流れて出る表示を作ってください。
要件:
- 回答の一覧は role="log" の領域に追記していく。
- 生成中の回答には aria-busy="true" を付け、終わったら false にする。
途中の文字が一文字ずつ読み上げられ続けるのを防ぐ、と理由を書く。
- 生成中は末尾に点滅する四角いカーソルを CSS の ::after で出す。
- 「止める」ボタンで AbortController を使って中断する。
実際の API では fetch に同じ signal を渡して通信ごと切る、
表示だけ止めると裏で生成が続き料金がかかる、とコメントで書く。
- 中断したら、そこまでの文章を残して「ここで止めました」と添える。
- 受け取った文字はテキストノードに足していく。innerHTML に連結すると
返ってきた文字列がタグとして解釈される、生成AIの出力は信用しない、と書く。
- prefers-reduced-motion のときは流さず、ためてから全文を一度に出す。
02 / OVERVIEWこの技術について
チャット画面の見た目は、吹き出しと入力欄だけです。だから作り始めるのは簡単で、壊れていることに気づくのは公開した後になります。
壊れ方には共通点があります。英語圏の実装をそのまま持ち込むと、日本語と読み上げの環境で必ず起きるということです。変換を確定しただけで送信される。読み返しているのに最下部へ引き戻される。生成中の文字が一文字ずつ読み上げられ続ける。ここではこの3つを先に潰し、その上に見た目を載せます。
送信キーは最初に決める
最初に決めるべきは、Enter を押したら何が起きるかです。日本語では、Enter は変換を確定するキーでもあります。何も考えずに「Enter で送信」を実装すると、「かんじ」を「漢字」に確定しただけで、書きかけのメッセージが送られます。
変換中の Enter は isComposing と keyCode 229 の両方で見分ける。片方だけだと、どれかのブラウザで必ず誤送信します。
スマホ向けなら、そもそも Enter を改行にしてボタンで送る形のほうが安全です。長文を改行しながら打つ使い方が一般的だからです。
吹き出しは話者の切れ目を見せる
吹き出しの仕事は、誰の発言かを示すことです。同じ人が続けて送ったときに毎回アイコンと名前を付けると、画面が同じ部品の繰り返しで埋まり、肝心の話者の切れ目が見えなくなります。
この判定は JavaScript を使わずに書けます。直前の要素が同じ話者かどうかは、CSS の隣接セレクタ(.msg.them + .msg.them)でそのまま表せるからです。
新着に追従するのは、最新を見ているときだけ
新しい発言が届いたら一番下へスクロールする。これを無条件にやると、上へ戻って読み返している人が、届くたびに最下部へ引き戻されます。会話が速い場面ほど、読んでいる場所を何度も見失います。
追従のスクロールは瞬時に行います。滑らかに動かすと、動いている途中に次の発言が届いたとき「下端から離れている」と判定されて、追従が切れてしまいます。滑らかにしてよいのは、利用者が「新着」を押して戻るときだけです。
流れて出る回答は、読み上げと停止を先に設計する
生成AIの回答を一文字ずつ出すのは、待ち時間を短く感じさせるためです。見た目だけを作ると、読み上げの環境では追記のたびに読み上げが走り、同じ文を何十回も聞かされることになります。
停止ボタンは表示を止めるだけでは足りません。実際の API では、fetch に AbortController の signal を渡して通信ごと切ってください。表示だけ止めると、裏で生成が続いて料金もかかり続けます。
崩れる形受け取った文字を innerHTML に連結する
回答にタグが含まれていると、それがそのまま解釈されます。生成AIの出力は、利用者の入力と同じく信用できない文字列として扱う必要があります。
効く形テキストノードに足していく
文字として扱われるので、タグが混ざっても実行されません。Markdown を整形したい場合は、変換後の HTML を許可リスト方式でサニタイズしてから入れます。
AIに書かせるときの一言
チャット画面をAIに作らせると、Enter 送信と無条件の自動スクロールがほぼ必ず付いてきます。英語圏の実装例が学習データの大半を占めるからです。指示の最後に「日本語入力の変換確定で送信しない」「読み返し中は自動スクロールしない」「生成中は aria-busy で読み上げを止める」の3行を足すだけで、返ってくるコードの質が変わります。
03 / NOTES使いどころとつまずきどころ
向いている場面
- 生成AIを使った対話型のアシスタント画面
- サイト右下に常駐する問い合わせ窓口
- コミュニティやグループの連絡機能
- 予約や配送のやり取りを会話で見せる画面
つまずきやすい点
- 変換を確定する Enter で送信されてしまう
- 読み返している最中に最下部へ引き戻される
- 生成中の文字が一文字ずつ読み上げられ続ける
- 長いURLが吹き出しを突き破って横にはみ出す
- 連投の名前を消すと、読み上げで話者が分からなくなる
04 / FAQよくある質問
Enter で送信とボタンで送信、どちらにすべき?
パソコン向けなら Enter 送信・Shift+Enter 改行が一般的です。スマホでは改行しながら長文を打つ人が多いので、Enter は改行にしてボタンで送る形が誤送信を防ぎます。どちらにしても、変換確定の Enter で送らない処理は必須です。
新着が来たら必ず一番下へスクロールすればいいのでは?
読み返している人を引き戻すことになります。会話が速い場面ほど、読んでいる途中で画面が飛んで何度も見失います。最新を見ているときだけ追従し、そうでないときは新着の件数を知らせるのが定石です。
回答を流して出すと、読み上げはどうなる?
何もしないと、追記のたびに読み上げが走り、同じ文を何十回も聞かされます。生成中は aria-busy="true" で保留し、終わってから全文を一度だけ読ませてください。
生成AIの回答を HTML として表示してもいい?
そのまま innerHTML に入れてはいけません。回答にタグが含まれると、それが実行される経路になります。Markdown を整形して見せたい場合も、変換後の HTML を許可リスト方式でサニタイズしてから入れてください。