タイムライン(Timeline)— チャット型
このコンポーネントについて
タイムラインというと業務ログのような縦一列表示を思い浮かべがちですが、多くの人が真っ先にイメージするのは LINEのようなチャットアプリの吹き出し画面ではないでしょうか。
このページでは、開発チーム3人のやり取りを想定し、JSONデータを fetch で読み込んで吹き出し形式で時系列表示するチャット型タイムラインを紹介します。
自分の発言は右寄せ・色付きバブル、相手の発言は左寄せ・アバター付きグレーバブルで表示し分け、チャットUIらしい見た目を再現しています。
受信中のタイムラインを想定し、表示後しばらくすると新着メッセージが自動で増えていく演出も加えており、チャットUIの基本構成をひと通り学べます。
- 吹き出し型レイアウト — 自分の発言は右寄せ・青系バブル、相手の発言は左寄せ・グレーバブル+イニシャルアバターで表示し分ける
- JSON読み込み —
fetchでメッセージデータ(JSON)を非同期に取得して描画する。データを差し替えるだけで実際のチャットAPIにも対応できる構造 - 新着の自動追加 — 初期3件表示のあと、4秒間隔で1件ずつ最大4件を自動追加し、追加のたびに最新メッセージの位置まで自動スクロールする
- 「次の新着を見る」ボタン — 自動追加を待たずに次の1件を即時表示する。タイマーをリセット&再始動し、出し切ったあとは「新着なし」表示に切り替えてdisabled化する
- 日付区切り表示 — やり取りの先頭に日付ラベルを表示し、チャットアプリらしい区切りを再現する
- リセットボタン — 表示を初期状態(3件)に戻し、自動追加を再スタートする
実装のポイント・注意点
吹き出しの左右振り分けは .ctl-msg--self / .ctl-msg--other の修飾クラスで切り替えます。
自分の発言には flex-direction: row-reverse を指定するだけで、アバターなしの右寄せレイアウトに反転できます。
データは fetch('./data/data.json') で取得し、配列をメモリに保持してから少しずつ描画します。
自動追加用の setInterval と「次の新着を見る」ボタンは同じ addNext() 関数を呼び出す設計にすることで、表示処理の二重実装を避けられます。
ボタン押下時は clearInterval でタイマーを止めてから即時に1件追加し、新しいタイマーで再開する順序を守ることが大切です。
最新メッセージへのスクロールは list.scrollTop = list.scrollHeight で実現します。
DOMに要素を追加した直後に呼び出さないと高さの計算がずれてスクロールしきらないことがあるため、appendChild の直後に実行するようにします。
動的なテキストの挿入はすべて textContent を使い、innerHTML に変数を直接代入しません。
外部から受け取ったデータを innerHTML に入れると XSS(クロスサイトスクリプティング)の脆弱性を生む恐れがあるため、コピペして使う場面でも安全な書き方を採用しています。
HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。
デモ
サンプルソース
4つのファイルを同じフォルダに保存し、ローカルサーバー(VS Code Live Server等)経由で
index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
+ data/ フォルダに data.json
— fetch を使用しているため file:// での直接表示は動作しません(CORSエラー)。
保存時の文字コードは UTF-8 を指定してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>チャット型タイムライン サンプル</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="ctl-wrap">
<p class="ctl-date-sep" id="js-date"></p>
<div class="ctl-list" id="js-list"></div>
</div>
<div class="demo-controls">
<button class="ctl-reload-btn" id="js-reload" onclick="loadNext()">次の新着を見る</button>
<button class="reset-btn" onclick="resetDemo()">リセット</button>
</div>
<script src="./script.js"></script>
</body>
</html>
/* チャット型タイムライン — style.css */
*, *::before, *::after { box-sizing: border-box; }
:root {
--ctl-self-bg: #2B7FE8;
--ctl-other-bg: #F1F5F9;
--ctl-text: #1E293B;
--ctl-muted: #94A3B8;
--ctl-avatar-bg: #CBD5E1;
}
body {
font-family: sans-serif;
padding: 24px;
max-width: 560px;
margin: 0 auto;
background: #fff;
color: var(--ctl-text);
}
/* ---- コンテナ ---- */
.ctl-wrap {
border: 1px solid #E2E8F0;
border-radius: 10px;
overflow: hidden;
}
/* チャットエリアをスクロール領域として固定 */
.ctl-list {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 420px;
overflow-y: auto;
padding: 16px;
}
/* 日付区切りラベル */
.ctl-date-sep {
text-align: center;
font-size: 12px;
color: var(--ctl-muted);
margin: 12px 0 0;
}
/* ---- メッセージ行 ---- */
.ctl-msg {
display: flex;
gap: 8px;
align-items: flex-end;
}
/* 自分の発言は逆順(バブルが右に来る) */
.ctl-msg--self {
flex-direction: row-reverse;
}
/* ---- アバター(イニシャル円形バッジ) ---- */
.ctl-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ctl-avatar-bg);
color: #475569;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
/* ---- 吹き出しラッパー(幅をここで決め、吹き出しはこれに合わせて広がる) ---- */
.ctl-body {
max-width: 70%;
}
/* ---- 吹き出し本体 ---- */
.ctl-bubble {
padding: 10px 14px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.ctl-msg--other .ctl-bubble {
background: var(--ctl-other-bg);
color: var(--ctl-text);
border-radius: 4px 16px 16px 16px;
}
.ctl-msg--self .ctl-bubble {
background: var(--ctl-self-bg);
color: #fff;
border-radius: 16px 4px 16px 16px;
}
/* ---- 送信者名 ---- */
.ctl-name {
font-size: 12px;
color: #64748B;
margin: 0 0 4px;
}
/* ---- 時刻 ---- */
.ctl-time {
display: block;
font-size: 11px;
color: var(--ctl-muted);
margin-top: 4px;
}
.ctl-msg--other .ctl-time { text-align: left; }
.ctl-msg--self .ctl-time { text-align: right; }
/* ---- 「次の新着を見る」ボタン ---- */
.ctl-reload-btn {
padding: 6px 16px;
font-size: 13px;
color: var(--ctl-self-bg);
background: #fff;
border: 1.5px solid var(--ctl-self-bg);
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
margin-right: 8px;
transition: background 0.15s;
}
.ctl-reload-btn:hover:not(:disabled) {
background: #EFF6FF;
}
.ctl-reload-btn:disabled {
color: var(--ctl-muted);
border-color: #D8E0E8;
background: #F4F6F9;
cursor: not-allowed;
}
/* ---- デモ操作エリア ---- */
.demo-controls {
margin-top: 12px;
}
.reset-btn {
padding: 6px 16px;
font-size: 13px;
color: #5A6A7A;
background: #fff;
border: 1.5px solid #D0D7E0;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s, border-color 0.15s;
}
.reset-btn:hover {
background: #F4F6F9;
border-color: #9AA5B4;
}
// ============================================================
// チャット型タイムライン — script.js
// ============================================================
// --- 状態管理変数 ---
var allMessages = []; // fetchで取得した全件データ
var shownCount = 0; // 表示済みの件数
var INITIAL_COUNT = 3; // 初期表示件数
var ADD_INTERVAL = 4000; // 自動追加の間隔(ミリ秒)
var timer = null; // 自動追加タイマー
// --- DOM要素を取得 ---
var dateEl = document.getElementById('js-date');
var listEl = document.getElementById('js-list');
var reloadEl = document.getElementById('js-reload');
// --- JSONを読み込んで初期化 ---
// 実際のチャットAPIに差し替える場合はここのURLを変更するだけでよい
fetch('./data/data.json')
.then(function(res) {
if (!res.ok) { throw new Error('HTTP ' + res.status); }
return res.json();
})
.then(function(data) {
allMessages = data.messages;
dateEl.textContent = data.date;
initialRender();
startAutoAdd();
})
.catch(function(err) {
console.error('読み込みエラー:', err);
var p = document.createElement('p');
p.style.cssText = 'color:#9B1C1C; padding:16px; font-size:14px;';
p.textContent = 'データを読み込めませんでした。ローカルサーバーで開いているか確認してください。';
listEl.appendChild(p);
});
// --- 初期表示(先頭3件を描画) ---
function initialRender() {
shownCount = 0;
for (var i = 0; i < INITIAL_COUNT && i < allMessages.length; i++) {
renderMessage(allMessages[i]);
shownCount++;
}
scrollToBottom();
}
// --- 自動追加タイマーを開始 ---
function startAutoAdd() {
timer = setInterval(addNext, ADD_INTERVAL);
}
// --- 次の1件を描画する(自動タイマー・ボタンの両方から呼ばれる共通処理) ---
function addNext() {
if (shownCount >= allMessages.length) { return; }
renderMessage(allMessages[shownCount]);
shownCount++;
scrollToBottom();
// 全件出し切ったらタイマーを止めてボタンを無効化する
if (shownCount >= allMessages.length) {
clearInterval(timer);
timer = null;
reloadEl.disabled = true;
reloadEl.textContent = '新着なし';
}
}
// --- 「次の新着を見る」ボタンの処理 ---
// 既存タイマーをリセットしてから即時に1件追加し、新しいタイマーで自動追加を続ける
function loadNext() {
if (reloadEl.disabled) { return; }
if (timer) {
clearInterval(timer);
timer = null;
}
addNext();
if (shownCount < allMessages.length) {
startAutoAdd();
}
}
// --- 1件分の吹き出しDOMを生成して追加する ---
function renderMessage(msg) {
var row = document.createElement('div');
row.className = 'ctl-msg ' + (msg.type === 'self' ? 'ctl-msg--self' : 'ctl-msg--other');
// 相手の発言にはアバターを表示する(自分の発言には付けない)
if (msg.type !== 'self') {
var avatar = document.createElement('span');
avatar.className = 'ctl-avatar';
avatar.textContent = msg.avatar;
row.appendChild(avatar);
}
var body = document.createElement('div');
body.className = 'ctl-body';
// 相手の発言には送信者名を表示する
if (msg.type !== 'self') {
var name = document.createElement('p');
name.className = 'ctl-name';
name.textContent = msg.sender;
body.appendChild(name);
}
var bubble = document.createElement('div');
bubble.className = 'ctl-bubble';
bubble.textContent = msg.text;
body.appendChild(bubble);
var time = document.createElement('span');
time.className = 'ctl-time';
time.textContent = msg.time;
body.appendChild(time);
row.appendChild(body);
listEl.appendChild(row);
}
// --- 最新メッセージの位置までスクロール ---
// appendChild の直後に呼ばないと高さの計算がずれて最後まで届かないことがある
function scrollToBottom() {
listEl.scrollTop = listEl.scrollHeight;
}
// --- リセット ---
function resetDemo() {
if (timer) {
clearInterval(timer);
timer = null;
}
listEl.textContent = '';
reloadEl.disabled = false;
reloadEl.textContent = '次の新着を見る';
initialRender();
startAutoAdd();
}
{
"date": "2026年6月8日",
"messages": [
{ "id": 1, "sender": "鈴木 健太", "type": "other", "avatar": "鈴", "time": "09:02", "text": "おはようございます。今日はAPIの実装の続きをやります" },
{ "id": 2, "sender": "田中 彩", "type": "self", "avatar": "田", "time": "09:05", "text": "おはようございます!よろしくお願いします" },
{ "id": 3, "sender": "佐藤 玲奈", "type": "other", "avatar": "佐", "time": "09:40", "text": "昨日のデザインレビューの指摘、修正版をあげました。確認お願いします" },
{ "id": 4, "sender": "田中 彩", "type": "self", "avatar": "田", "time": "10:15", "text": "確認しました、ありがとうございます。問題なさそうです" },
{ "id": 5, "sender": "鈴木 健太", "type": "other", "avatar": "鈴", "time": "11:30", "text": "APIの実装が終わったので、テストの方をお願いできますか?" },
{ "id": 6, "sender": "佐藤 玲奈", "type": "other", "avatar": "佐", "time": "13:00", "text": "了解しました、午後にテストします" },
{ "id": 7, "sender": "田中 彩", "type": "self", "avatar": "田", "time": "15:20", "text": "ありがとうございます。明日の朝会で進捗を共有しましょう" }
]
}
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や項目変更など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# タイムライン(チャット型)作成依頼
## 概要
チームメンバー間のやり取りを、LINEのような吹き出し形式で時系列表示するチャット風タイムラインを実装してください。
JSONファイルをfetchで読み込み、初期表示後は新着メッセージが自動で増えていく演出を加えます。
## 要件
- JSONファイル(messages配列: id / sender / type / avatar / time / text)をfetchで取得して描画する
- 自分(type: "self")の発言は右寄せ・色付き吹き出し、相手(type: "other")の発言は左寄せ・グレー吹き出し+イニシャルアバターで表示する
- 相手の発言には送信者名を吹き出しの上に表示する
- 各吹き出しに時刻を表示する
- 一覧の先頭に日付区切りラベルを表示する
- 初期表示は先頭3件のみとし、4秒間隔で残りのメッセージを1件ずつ自動追加する
- メッセージを追加するたびに最新メッセージの位置まで自動スクロールする
- 「次の新着を見る」ボタンを設置し、押すと自動追加を待たずに次の1件を即時表示する(自動追加のタイマーもリセットして継続する)
- 追加分をすべて表示し終えたら、「次の新着を見る」ボタンのラベルを「新着なし」に変更してdisabledにする
- リセットボタンで初期状態(先頭3件のみ表示)に戻し、ボタンの状態も初期化したうえで自動追加を再スタートする
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- fetch API で JSON ファイルを非同期に取得する
- レスポンシブ対応:必要
## 動作詳細
fetch('./data/data.json').then(res => res.json()) でメッセージ配列を取得し、全件をメモリに保持する。
初期表示では先頭3件のみ renderMessage() で描画し、setInterval で4秒間隔の自動追加タイマーを開始する。
自動追加とボタン押下はどちらも同じ addNext() 関数を呼び、次の1件を描画したあと自動スクロールを行う。
「次の新着を見る」ボタン押下時は、既存のタイマーを clearInterval でリセットしてから addNext() を実行し、新しいタイマーで自動追加を再始動する。
全件を描画し終えたら setInterval を停止し、ボタンのラベルを「新着なし」に変更して disabled にする。
リセット時はリストを空にして表示件数を3件に戻し、ボタンの状態も初期化したうえでタイマーを再始動する。
動的データの描画には textContent を使用し、innerHTML に変数を直接渡さない(XSS対策)。
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。