Bootstrap 5 Chat Room Layout with Conversation List
A two-pane chat room with a searchable conversation list, message bubbles with timestamps, an options menu and a working composer.
<section class="wdw-stage">
<div class="container" style="max-width:1040px">
<div class="wdw-card cr overflow-hidden">
<aside class="cr-side">
<div class="p-3 pb-2">
<h1 class="h5 wdw-heading mb-3">Messages</h1>
<label class="visually-hidden" for="crSearch">Search conversations</label>
<input type="search" id="crSearch" class="form-control rounded-pill" placeholder="Search conversations">
</div>
<div class="cr-list px-2 pb-2" id="crList">
<button type="button" class="cr-conv active" data-name="Maya Chen" data-img="5" aria-pressed="true"><span class="cr-av"><img src="https://i.pravatar.cc/80?img=5" width="44" height="44" class="rounded-circle" alt=""><span class="cr-dot"></span></span><span class="cr-conv-main"><span class="d-flex justify-content-between gap-2"><strong class="text-truncate">Maya Chen</strong><small class="wdw-muted flex-shrink-0">09:45</small></span><span class="d-flex justify-content-between gap-2"><span class="wdw-muted small text-truncate">Sounds good, see you then!</span><span class="badge rounded-pill text-bg-primary">2</span></span></span></button>
<button type="button" class="cr-conv" data-name="Design Team" data-img="" data-group="1" aria-pressed="false"><span class="cr-av"><span class="cr-group rounded-circle" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 5.2a3.2 3.2 0 0 1 0 5.6M18 14.4c2 .8 3.5 2.8 3.5 5.6"/></svg></span></span><span class="cr-conv-main"><span class="d-flex justify-content-between gap-2"><strong class="text-truncate">Design Team</strong><small class="wdw-muted flex-shrink-0">09:12</small></span><span class="d-flex justify-content-between gap-2"><span class="wdw-muted small text-truncate">Leo (member): Updated the mockups</span></span></span></button>
<button type="button" class="cr-conv" data-name="Jack Sparrow" data-img="15" aria-pressed="false"><span class="cr-av"><img src="https://i.pravatar.cc/80?img=15" width="44" height="44" class="rounded-circle" alt=""></span><span class="cr-conv-main"><span class="d-flex justify-content-between gap-2"><strong class="text-truncate">Jack Sparrow</strong><small class="wdw-muted flex-shrink-0">Yesterday</small></span><span class="d-flex justify-content-between gap-2"><span class="wdw-muted small text-truncate">Can you send the file?</span><span class="badge rounded-pill text-bg-primary">3</span></span></span></button>
<button type="button" class="cr-conv" data-name="Priya Nair" data-img="47" aria-pressed="false"><span class="cr-av"><img src="https://i.pravatar.cc/80?img=47" width="44" height="44" class="rounded-circle" alt=""></span><span class="cr-conv-main"><span class="d-flex justify-content-between gap-2"><strong class="text-truncate">Priya Nair</strong><small class="wdw-muted flex-shrink-0">Yesterday</small></span><span class="d-flex justify-content-between gap-2"><span class="wdw-muted small text-truncate">Thanks a lot</span></span></span></button>
<button type="button" class="cr-conv" data-name="Tom Becker" data-img="8" aria-pressed="false"><span class="cr-av"><img src="https://i.pravatar.cc/80?img=8" width="44" height="44" class="rounded-circle" alt=""><span class="cr-dot"></span></span><span class="cr-conv-main"><span class="d-flex justify-content-between gap-2"><strong class="text-truncate">Tom Becker</strong><small class="wdw-muted flex-shrink-0">Mon</small></span><span class="d-flex justify-content-between gap-2"><span class="wdw-muted small text-truncate">Let's catch up soon</span></span></span></button>
</div>
</aside>
<div class="cr-main">
<header class="d-flex align-items-center gap-3 px-4 py-3 border-bottom">
<span class="cr-av"><img id="crAvatar" src="https://i.pravatar.cc/80?img=5" width="44" height="44" class="rounded-circle" alt=""><span class="cr-group rounded-circle" id="crGroup" hidden aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 5.2a3.2 3.2 0 0 1 0 5.6M18 14.4c2 .8 3.5 2.8 3.5 5.6"/></svg></span><span class="cr-dot" id="crDot"></span></span>
<div class="me-auto"><strong id="crName" class="d-block">Maya Chen</strong><small class="text-body-secondary d-inline-flex align-items-center gap-2" id="crStatus"><span class="cr-pip" aria-hidden="true"></span><span id="crStatusText">Online</span></small></div>
<div class="dropdown">
<button class="btn btn-sm rounded-pill px-3 cr-opt" type="button" data-bs-toggle="dropdown" aria-expanded="false">Options</button>
<ul class="dropdown-menu dropdown-menu-end border-0"><li><button class="dropdown-item" type="button">Mute</button></li><li><button class="dropdown-item" type="button">Pin conversation</button></li><li><hr class="dropdown-divider"></li><li><button class="dropdown-item text-danger" type="button">Clear chat</button></li></ul>
</div>
</header>
<div class="cr-thread px-4 py-4" id="crThread" aria-live="polite">
<div class="cr-day">Today</div>
<div class="cr-msg"><p>Hi! Did you get a chance to look at the new landing page draft?</p><time>09:38</time></div>
<div class="cr-msg me"><p>Yes, it looks great. I left a few notes on the hero section.</p><time>09:40</time></div>
<div class="cr-msg"><p>Perfect, I'll update it this afternoon. Free for a quick call at 3?</p><time>09:44</time></div>
<div class="cr-msg me"><p>Sounds good, see you then!</p><time>09:45</time></div>
</div>
<form class="d-flex gap-2 p-3 border-top" id="crForm">
<label class="visually-hidden" for="crInput">Message</label>
<button type="button" class="btn cr-ico rounded-circle p-0" aria-label="Attach a file"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21 11-8.6 8.6a5 5 0 0 1-7-7L14 4a3.4 3.4 0 0 1 5 5l-8.6 8.6a1.7 1.7 0 0 1-2.4-2.4L15 8"/></svg></button>
<button type="button" class="btn cr-ico rounded-circle p-0 d-none d-sm-inline-flex" aria-label="Insert emoji"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/></svg></button>
<input type="text" id="crInput" class="form-control rounded-pill" placeholder="Write a message" autocomplete="off">
<button type="submit" class="btn btn-primary rounded-pill px-4">Send</button>
</form>
</div>
</div>
</div>
</section>
/* Web Designer Wall library style — shared base for every Bootstrap 5 snippet.
Indigo accent, Plus Jakarta Sans, soft cards, pill buttons. Works in light and dark. */
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap");
:root,
[data-bs-theme="light"] {
--wdw-font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--bs-body-font-family: var(--wdw-font);
--bs-primary: #4f46e5;
--bs-primary-rgb: 79, 70, 229;
--bs-primary-text-emphasis: #3730a3;
--bs-primary-bg-subtle: #eef2ff;
--bs-primary-border-subtle: #c7d2fe;
--bs-link-color: #4f46e5;
--bs-link-color-rgb: 79, 70, 229;
--bs-link-hover-color: #4338ca;
--bs-link-hover-color-rgb: 67, 56, 202;
--bs-focus-ring-color: rgba(79, 70, 229, 0.25);
--bs-border-radius: 0.75rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.25rem;
--wdw-radius-card: 1.25rem;
--wdw-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 16px 40px -16px rgba(16, 24, 40, 0.22);
--wdw-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 12px -4px rgba(16, 24, 40, 0.12);
--wdw-stage: #f5f6fb;
}
[data-bs-theme="dark"] {
--bs-primary: #818cf8;
--bs-primary-rgb: 129, 140, 248;
--bs-primary-text-emphasis: #c7d2fe;
--bs-primary-bg-subtle: rgba(129, 140, 248, 0.14);
--bs-primary-border-subtle: rgba(129, 140, 248, 0.35);
--bs-link-color: #a5b4fc;
--bs-link-color-rgb: 165, 180, 252;
--bs-link-hover-color: #c7d2fe;
--bs-link-hover-color-rgb: 199, 210, 254;
--bs-focus-ring-color: rgba(129, 140, 248, 0.35);
--wdw-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -16px rgba(0, 0, 0, 0.7);
--wdw-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px -4px rgba(0, 0, 0, 0.5);
--wdw-stage: #0f1117;
}
body { font-family: var(--wdw-font); -webkit-font-smoothing: antialiased; }
/* Bootstrap compiles its blue into these components; point them at the accent. */
.btn-primary {
--bs-btn-bg: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: #4338ca; --bs-btn-hover-border-color: #4338ca;
--bs-btn-active-bg: #3730a3; --bs-btn-active-border-color: #3730a3;
--bs-btn-disabled-bg: var(--bs-primary); --bs-btn-disabled-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
[data-bs-theme="dark"] .btn-primary { --bs-btn-color: #0f1117; --bs-btn-hover-bg: #a5b4fc; --bs-btn-hover-border-color: #a5b4fc; --bs-btn-hover-color: #0f1117; --bs-btn-active-color: #0f1117; }
.btn-outline-primary {
--bs-btn-color: var(--bs-primary); --bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary); --bs-btn-hover-border-color: var(--bs-primary);
--bs-btn-active-bg: var(--bs-primary); --bs-btn-active-border-color: var(--bs-primary);
--bs-btn-focus-shadow-rgb: var(--bs-primary-rgb);
}
.btn { --bs-btn-font-weight: 600; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: .5rem; }
.btn-sm { min-height: 36px; }
.form-control, .form-select { min-height: 44px; border-radius: .75rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: rgba(var(--bs-primary-rgb), .6); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-check-input:checked { background-color: var(--bs-primary); border-color: var(--bs-primary); }
.nav-pills { --bs-nav-pills-link-active-bg: var(--bs-primary); }
.nav { --bs-nav-link-color: var(--bs-secondary-color); --bs-nav-link-hover-color: var(--bs-emphasis-color); }
.pagination { --bs-pagination-active-bg: var(--bs-primary); --bs-pagination-active-border-color: var(--bs-primary); --bs-pagination-color: var(--bs-body-color); --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.progress, .progress-stacked { --bs-progress-bar-bg: var(--bs-primary); }
.list-group { --bs-list-group-active-bg: var(--bs-primary); --bs-list-group-active-border-color: var(--bs-primary); }
.dropdown-menu { --bs-dropdown-link-active-bg: var(--bs-primary); border-radius: .875rem; box-shadow: var(--wdw-shadow-sm); }
.accordion { --bs-accordion-active-bg: var(--bs-primary-bg-subtle); --bs-accordion-active-color: var(--bs-primary-text-emphasis); --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.badge { font-weight: 600; }
/* Library building blocks — use these instead of re-inventing them per snippet. */
.wdw-stage { min-height: 100vh; display: flex; align-items: center; background: var(--wdw-stage); padding-block: 3rem; }
.wdw-card { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: var(--wdw-radius-card); box-shadow: var(--wdw-shadow); }
.wdw-card-sm { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color-translucent); border-radius: 1rem; box-shadow: var(--wdw-shadow-sm); }
.wdw-aurora { position: relative; color: #fff; background: radial-gradient(60% 55% at 100% 0, #818cf8 0, transparent 70%), radial-gradient(65% 60% at 0 100%, #ec4899 0, transparent 65%), #1e1b4b; }
.wdw-eyebrow { display: inline-block; font-size: .75rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--bs-primary); }
.wdw-heading { font-weight: 800; letter-spacing: -.02em; color: var(--bs-emphasis-color); }
.wdw-muted { color: var(--bs-secondary-color); }
.wdw-icon { width: 2.75rem; height: 2.75rem; flex: none; display: inline-flex; align-items: center; justify-content: center; border-radius: .875rem; background: var(--bs-primary-bg-subtle); color: var(--bs-primary); }
/* Component */
.cr { display: grid; grid-template-columns: 320px 1fr; height: 600px; }
.cr-side { border-right: 1px solid var(--bs-border-color); display: flex; flex-direction: column; min-height: 0; background: var(--bs-tertiary-bg); }
.cr-list { overflow-y: auto; display: flex; flex-direction: column; gap: .125rem; }
.cr-conv { display: flex; gap: .75rem; align-items: center; width: 100%; text-align: left; border: 0; background: transparent; padding: .75rem; border-radius: .875rem; color: var(--bs-body-color); }
.cr-conv:hover { background: var(--bs-body-bg); }
.cr-conv.active { background: var(--bs-body-bg); box-shadow: var(--wdw-shadow-sm); }
.cr-conv-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .15rem; }
.cr-conv img { object-fit: cover; flex: none; }
.cr-main { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.cr-main > header img { object-fit: cover; }
.cr-thread { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: .75rem; }
.cr-day { align-self: center; font-size: .75rem; font-weight: 600; color: var(--bs-secondary-color); background: var(--bs-tertiary-bg); padding: .2rem .75rem; border-radius: 999px; }
.cr-msg { max-width: 75%; align-self: flex-start; }
.cr-msg p { margin: 0; padding: .65rem 1rem; background: var(--bs-tertiary-bg); border-radius: 1.125rem 1.125rem 1.125rem .25rem; }
.cr-msg time { display: block; font-size: .7rem; color: var(--bs-secondary-color); margin: .2rem .5rem 0; }
.cr-msg.me { align-self: flex-end; }
.cr-msg.me p { background: var(--bs-primary); color: #fff; border-radius: 1.125rem 1.125rem .25rem 1.125rem; }
[data-bs-theme="dark"] .cr-msg.me p { color: #0f1117; }
.cr-msg.me time { text-align: right; }
@media (max-width: 767.98px) {
.cr { grid-template-columns: minmax(0,1fr); height: auto; }
.cr-side { min-width: 0; border-right: 0; border-bottom: 1px solid var(--bs-border-color); }
.cr-list { flex-direction: row; overflow-x: auto; overflow-y: hidden; }
.cr-conv { width: 230px; flex: none; }
.cr-main { height: 560px; }
}
.cr-av { position: relative; flex: none; width: 44px; height: 44px; display: inline-block; }
.cr-av img { display: block; }
.cr-group { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); }
.cr-group[hidden] { display: none; }
.cr-dot { position: absolute; right: 0; bottom: 0; width: 12px; height: 12px; border-radius: 50%; background: #22c55e; border: 2px solid var(--bs-body-bg); }
.cr-dot[hidden] { display: none; }
.cr-pip { width: .5rem; height: .5rem; border-radius: 50%; background: #22c55e; }
.cr-pip.off { background: var(--bs-secondary-color); }
.cr-opt { border: 1px solid var(--bs-border-color); color: var(--bs-body-color); background: transparent; }
.cr-opt:hover, .cr-opt:focus-visible { background: var(--bs-tertiary-bg); color: var(--bs-emphasis-color); border-color: var(--bs-border-color); }
.cr-ico { width: 44px; height: 44px; flex: none; color: var(--bs-secondary-color); }
.cr-ico:hover { background: var(--bs-tertiary-bg); color: var(--bs-emphasis-color); }
.cr-conv:focus-visible { outline: 0; box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); background: var(--bs-body-bg); }
.cr-typing p { display: inline-flex; gap: 4px; padding: .85rem 1rem; }
.cr-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--bs-secondary-color); animation: crBlink 1s infinite; }
.cr-typing i:nth-child(2) { animation-delay: .15s; } .cr-typing i:nth-child(3) { animation-delay: .3s; }
@keyframes crBlink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .cr-typing i { animation: none; } }
@media (max-width: 767.98px) {
.cr-list { scroll-snap-type: x mandatory; scroll-padding-inline: .5rem; padding-right: 2.5rem !important; }
.cr-conv { width: 220px; scroll-snap-align: start; }
.cr-thread { padding-top: 1rem !important; }
}
(function initChatRoom() {
var thread = document.getElementById('crThread');
var form = document.getElementById('crForm');
var input = document.getElementById('crInput');
var list = document.getElementById('crList');
var search = document.getElementById('crSearch');
if (!thread || !form || !input) return;
var chats = {
'Maya Chen': [['Hi! Did you get a chance to look at the new landing page draft?'], ['Yes, it looks great. I left a few notes on the hero section.', 1], ['Perfect, I\'ll update it this afternoon. Free for a quick call at 3?'], ['Sounds good, see you then!', 1]],
'Design Team': [['Leo: Updated the mockups for the pricing page.'], ['Nina: Love the new card layout.'], ['Thanks both, shipping the final set tomorrow.', 1]],
'Jack Sparrow': [['Morning! Can you send the file?'], ['Sure, which version do you need?', 1], ['The one with the revised logo, please.']],
'Priya Nair': [['The invoice is paid, all set on our side.'], ['Great, thanks for the quick turnaround!', 1], ['Thanks a lot']],
'Tom Becker': [['Long time no see. How is the new job?'], ['Busy but good. Coffee next week?', 1], ['Let\'s catch up soon']]
};
var status = { 'Maya Chen': 1, 'Tom Becker': 1 };
var times = ['09:38', '09:40', '09:44', '09:45'];
function time() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
function bubble(text, me, t) {
var box = document.createElement('div');
box.className = 'cr-msg' + (me ? ' me' : '');
var p = document.createElement('p'); p.textContent = text;
var tm = document.createElement('time'); tm.textContent = t || time();
box.append(p, tm);
return box;
}
function add(text, me) { thread.appendChild(bubble(text, me)); thread.scrollTop = thread.scrollHeight; }
function render(name) {
thread.textContent = '';
var day = document.createElement('div'); day.className = 'cr-day'; day.textContent = 'Today';
thread.appendChild(day);
(chats[name] || []).forEach(function (m, i) { thread.appendChild(bubble(m[0], !!m[1], times[i % 4])); });
thread.scrollTop = window.matchMedia('(max-width: 767.98px)').matches ? 0 : thread.scrollHeight;
}
render('Maya Chen');
form.addEventListener('submit', function (e) {
e.preventDefault();
var v = input.value.trim();
if (!v) return;
add(v, true);
input.value = '';
var typing = document.createElement('div');
typing.className = 'cr-msg cr-typing';
typing.innerHTML = '<p aria-label="Typing"><i></i><i></i><i></i></p>';
setTimeout(function () { thread.appendChild(typing); thread.scrollTop = thread.scrollHeight; }, 300);
setTimeout(function () { typing.remove(); add('Got it, thanks!', false); }, 1400);
});
if (!list) return;
list.addEventListener('click', function (e) {
var btn = e.target.closest('.cr-conv');
if (!btn) return;
list.querySelectorAll('.cr-conv').forEach(function (b) { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true');
var name = btn.dataset.name, group = btn.dataset.group === '1';
document.getElementById('crName').textContent = name;
var av = document.getElementById('crAvatar');
av.hidden = group;
if (!group) av.src = 'https://i.pravatar.cc/80?img=' + btn.dataset.img;
document.getElementById('crGroup').hidden = !group;
var on = !!status[name];
document.getElementById('crDot').hidden = !on;
document.getElementById('crStatusText').textContent = group ? '3 members' : (on ? 'Online' : 'Away');
document.querySelector('#crStatus .cr-pip').classList.toggle('off', !on);
var badge = btn.querySelector('.badge');
if (badge) badge.remove();
render(name);
});
if (search) search.addEventListener('input', function () {
var q = search.value.toLowerCase();
list.querySelectorAll('.cr-conv').forEach(function (b) { b.hidden = b.dataset.name.toLowerCase().indexOf(q) === -1; });
});
})();
<section class="chat-stage">
<div class="chat-app">
<aside class="chat-side" id="chatSide" aria-label="Conversations">
<div class="chat-side-head">
<label class="form-control form-adorn">
<span class="form-adorn-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3.5 3.5"/></svg></span>
<input class="form-ghost" type="search" placeholder="Search conversations" aria-label="Search conversations">
</label>
<div class="chat-filter">
<button class="btn-subtle theme-secondary btn-sm chat-filter-btn" type="button" data-bs-toggle="menu" aria-expanded="false">
<span id="chatFilterLabel">All conversations</span>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 6 5 5 5-5"/></svg>
</button>
<div class="menu" id="chatFilterMenu">
<button type="button" class="menu-item selected" data-filter="all">All conversations</button>
<button type="button" class="menu-item" data-filter="unread">Unread</button>
<button type="button" class="menu-item" data-filter="groups">Groups</button>
<hr class="menu-divider">
<button type="button" class="menu-item" data-filter="archived">Archived</button>
</div>
</div>
</div>
<ul class="chat-list" id="chatList" role="list">
<li class="chat-item">
<button type="button" class="chat-conv is-active" data-thread="jack" data-name="Jack Sparrow" data-img="https://i.pravatar.cc/80?img=12" data-status="online" data-status-label="Online" aria-current="true">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=12" alt="" width="40" height="40"><span class="avatar-status status-online"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Jack Sparrow</strong><time>09:45</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Port is ready when you are.</span><span class="badge theme-primary">3</span></span>
</span>
</button>
</li>
<li class="chat-item">
<button type="button" class="chat-conv" data-thread="elena" data-name="Elena Marsh" data-img="https://i.pravatar.cc/80?img=47" data-status="online" data-status-label="Online">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=47" alt="" width="40" height="40"><span class="avatar-status status-online"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Elena Marsh</strong><time>09:12</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Sent the updated brief.</span><span class="badge theme-primary">1</span></span>
</span>
</button>
</li>
<li class="chat-item">
<button type="button" class="chat-conv" data-thread="guild" data-name="Design Guild" data-img="https://i.pravatar.cc/80?img=32" data-status="away" data-status-label="Away" data-type="group">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=32" alt="" width="40" height="40"><span class="avatar-status status-away"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Design Guild</strong><time>Yesterday</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Priya: new mockups are in</span></span>
</span>
</button>
</li>
<li class="chat-item">
<button type="button" class="chat-conv" data-thread="tomas" data-name="Tomas Berg" data-img="https://i.pravatar.cc/80?img=52" data-status="offline" data-status-label="Last seen yesterday">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=52" alt="" width="40" height="40"><span class="avatar-status status-offline"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Tomas Berg</strong><time>Yesterday</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Thanks, that works!</span></span>
</span>
</button>
</li>
<li class="chat-item">
<button type="button" class="chat-conv" data-thread="amara" data-name="Amara Okafor" data-img="https://i.pravatar.cc/80?img=5" data-status="offline" data-status-label="Last seen Monday">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=5" alt="" width="40" height="40"><span class="avatar-status status-offline"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Amara Okafor</strong><time>Mon</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Can we move it to Thursday?</span></span>
</span>
</button>
</li>
<li class="chat-item">
<button type="button" class="chat-conv" data-thread="support" data-name="Support Team" data-img="https://i.pravatar.cc/80?img=60" data-status="online" data-status-label="Online">
<span class="avatar"><img class="avatar-img" src="https://i.pravatar.cc/80?img=60" alt="" width="40" height="40"><span class="avatar-status status-online"></span></span>
<span class="chat-conv-body">
<span class="chat-conv-top"><strong>Support Team</strong><time>Mon</time></span>
<span class="chat-conv-bottom"><span class="chat-conv-msg">Ticket #4821 resolved</span></span>
</span>
</button>
</li>
</ul>
<p class="chat-empty fg-3" id="chatEmpty" hidden>No conversations here.</p>
</aside>
<div class="chat-main">
<header class="chat-main-head">
<button class="btn-icon md:d-none" type="button" id="chatListToggle" aria-label="Show conversations" aria-expanded="false" aria-controls="chatSide">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></svg>
</button>
<span class="avatar"><img class="avatar-img" id="chatHeadImg" src="https://i.pravatar.cc/80?img=12" alt="" width="40" height="40"><span class="avatar-status status-online" id="chatHeadStatus"></span></span>
<div class="chat-who">
<strong id="chatHeadName">Jack Sparrow</strong>
<span class="fs-sm fg-secondary" id="chatHeadSub">Online</span>
</div>
<button class="btn-text theme-secondary btn-sm" type="button" id="chatNew" aria-label="New message">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
<span class="d-none md:d-inline">New message</span>
</button>
<div class="chat-settings">
<button class="btn-icon" type="button" data-bs-toggle="menu" data-bs-placement="bottom-end" aria-expanded="false" aria-label="Settings">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="8" cy="3" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="8" cy="13" r="1.3"/></svg>
</button>
<div class="menu">
<button type="button" class="menu-item">Edit profile</button>
<button type="button" class="menu-item">Notifications</button>
<hr class="menu-divider">
<button type="button" class="menu-item">Log in as another user</button>
<button type="button" class="menu-item">Log out</button>
</div>
</div>
</header>
<div class="chat-messages" id="chatMessages" role="log" aria-live="polite">
<p class="chat-day">Today</p>
<div id="chatThread" class="chat-thread">
<div class="chat-row">
<span class="avatar avatar-sm"><img class="avatar-img" src="https://i.pravatar.cc/80?img=12" alt="" width="32" height="32"></span>
<div><p class="chat-bubble">Morning! Did you get a chance to look at the route plan for the weekend?</p><time class="chat-time">09:40</time></div>
</div>
<div class="chat-row chat-row-out">
<div><p class="chat-bubble">Just did. I moved the second stop to Friday so we avoid the storm.</p><time class="chat-time">09:41</time></div>
</div>
<div class="chat-row">
<span class="avatar avatar-sm"><img class="avatar-img" src="https://i.pravatar.cc/80?img=12" alt="" width="32" height="32"></span>
<div><p class="chat-bubble">Perfect. I will upload the updated chart in a minute.</p><time class="chat-time">09:45</time></div>
</div>
<div class="chat-row chat-row-out">
<div><p class="chat-bubble">Great, thanks. Send it over whenever it is ready.</p><time class="chat-time">09:46</time></div>
</div>
</div>
</div>
<form class="chat-compose" id="chatForm" autocomplete="off">
<input type="file" id="chatFile" hidden>
<button class="btn-icon" type="button" id="chatAttach" aria-label="Attach a file">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 18a4 4 0 0 1-.5-7.97A6 6 0 0 1 18 9a4 4 0 0 1 0 8.5"/><path d="m12 12-3 3m3-3 3 3m-3-3v8"/></svg>
</button>
<input class="form-control" type="text" id="chatInput" placeholder="Type your message here" aria-label="Message">
<button class="btn-solid theme-primary" type="submit">Send</button>
</form>
</div>
</div>
</section>
.chat-stage { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem 1rem; background: var(--bs-bg-2); }
.chat-app { display: grid; grid-template-columns: 1fr; width: 100%; max-width: 1040px; height: min(680px, calc(100vh - 3rem)); min-height: 520px; overflow: hidden; background: var(--bs-bg-body); border: 1px solid var(--bs-border-color); border-radius: var(--bs-radius-6); box-shadow: 0 24px 60px -30px rgb(0 0 0 / .3); }
.chat-side { display: none; flex-direction: column; min-height: 0; border-inline-end: 1px solid var(--bs-border-color); background: var(--bs-bg-1); }
.chat-side-head { display: flex; flex-direction: column; gap: .75rem; padding: 1rem; }
.chat-list { flex: 1; margin: 0; padding: .25rem .5rem 1rem; list-style: none; overflow-y: auto; }
.chat-conv { display: flex; align-items: center; gap: .75rem; width: 100%; padding: .625rem .75rem; text-align: start; color: inherit; background: none; border: 0; border-radius: var(--bs-radius-5); cursor: pointer; }
.chat-conv:hover { background: var(--bs-bg-2); }
.chat-conv.is-active { background: var(--bs-bg-body); box-shadow: 0 0 0 1px var(--bs-border-color); }
.chat-conv:focus-visible { outline: 2px solid var(--bs-primary-base); outline-offset: 1px; }
.chat-conv-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .125rem; }
.chat-conv-top, .chat-conv-bottom { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.chat-conv-top strong { font-size: .875rem; font-weight: 600; }
.chat-conv-top time { font-size: .75rem; color: var(--bs-fg-3); }
.chat-conv-msg { overflow: hidden; font-size: .8125rem; color: var(--bs-fg-3); white-space: nowrap; text-overflow: ellipsis; }
.chat-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-main-head { display: flex; align-items: center; gap: .75rem; padding: .875rem 1.25rem; border-bottom: 1px solid var(--bs-border-color); }
.chat-who { display: flex; flex-direction: column; flex: 1; line-height: 1.3; }
.chat-messages { flex: 1; display: flex; flex-direction: column; gap: 1rem; padding: 1.25rem; overflow-y: auto; background: var(--bs-bg-1); }
.chat-day { align-self: center; margin: 0; padding: .125rem .75rem; font-size: .75rem; color: var(--bs-fg-3); background: var(--bs-bg-3); border-radius: 999px; }
.chat-row { display: flex; align-items: flex-end; gap: .625rem; max-width: 85%; }
.chat-row-out { align-self: flex-end; flex-direction: row-reverse; }
.chat-bubble { margin: 0; padding: .625rem .875rem; font-size: .9375rem; line-height: 1.45; background: var(--bs-bg-body); border: 1px solid var(--bs-border-color); border-radius: 1rem 1rem 1rem .25rem; }
.chat-row-out .chat-bubble { color: #fff; background: color-mix(in oklch, var(--bs-primary-base) 80%, black); border-color: transparent; border-radius: 1rem 1rem .25rem 1rem; }
.chat-time { display: block; margin-top: .25rem; font-size: .6875rem; color: var(--bs-fg-3); }
.chat-row-out .chat-time { text-align: end; }
.chat-compose { display: flex; align-items: center; gap: .5rem; padding: .875rem 1rem; border-top: 1px solid var(--bs-border-color); }
.chat-compose .form-control { flex: 1; min-width: 0; }
.chat-filter, .chat-settings { position: relative; }
.chat-filter-btn { justify-content: space-between; width: 100%; }
.chat-thread { display: flex; flex-direction: column; gap: 1rem; }
.chat-empty { margin: 0; padding: 1rem; text-align: center; font-size: .875rem; }
.chat-item[hidden], .chat-empty[hidden] { display: none; }
.chat-side .avatar { --bs-avatar-status-border-color: var(--bs-bg-1); }
.chat-conv.is-active .avatar { --bs-avatar-status-border-color: var(--bs-bg-body); }
.avatar-status.status-offline { background-color: var(--bs-gray-500); border-radius: 50%; }
.chat-app.is-list-open .chat-side { display: flex; }
.chat-app.is-list-open .chat-main { display: none; }
@media (width >= 768px) {
.chat-app { grid-template-columns: 320px 1fr; }
.chat-side { display: flex; }
.chat-app.is-list-open .chat-main { display: flex; }
}
.chat-row .avatar, .chat-conv .avatar, .chat-main-head .avatar { flex: none; }
const THREADS = {
jack: null, // the first thread is server-rendered in the markup
elena: [['in', 'Sent the updated brief. Can you review the scope section?', '09:10'], ['out', 'On it. I will leave comments before lunch.', '09:12'], ['in', 'Thanks, that is all I need.', '09:12']],
guild: [['in', 'Priya: new mockups are in the shared folder.', 'Yesterday'], ['out', 'Looks great. The onboarding screens are my favourite.', 'Yesterday']],
tomas: [['out', 'Invoice 118 is attached to the project page.', 'Yesterday'], ['in', 'Thanks, that works!', 'Yesterday']],
amara: [['in', 'Can we move the call to Thursday afternoon?', 'Mon'], ['out', 'Thursday at 3pm works for me.', 'Mon']],
support: [['in', 'Ticket #4821 is resolved. Let us know if it comes back.', 'Mon']]
}
const el = (tag, className, text) => {
const node = document.createElement(tag)
if (className) node.className = className
if (text) node.textContent = text
return node
}
const initChat = () => {
const app = document.querySelector('.chat-app')
const form = document.getElementById('chatForm')
const input = document.getElementById('chatInput')
const log = document.getElementById('chatMessages')
const thread = document.getElementById('chatThread')
if (!app || !form || !input || !log || !thread) return
const time = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const addMessage = (text, dir = 'out', stamp = time(), avatarSrc = '') => {
const row = el('div', dir === 'out' ? 'chat-row chat-row-out' : 'chat-row')
if (dir === 'in') {
const avatar = el('span', 'avatar avatar-sm')
const img = el('img', 'avatar-img')
img.src = avatarSrc || document.getElementById('chatHeadImg').src
img.alt = ''
img.width = img.height = 32
avatar.append(img)
row.append(avatar)
}
const wrap = el('div')
wrap.append(el('p', 'chat-bubble', text), el('time', 'chat-time', stamp))
row.append(wrap)
thread.append(row)
log.scrollTop = log.scrollHeight
}
form.addEventListener('submit', (e) => {
e.preventDefault()
const text = input.value.trim()
if (!text) return
addMessage(text)
input.value = ''
})
const attach = document.getElementById('chatAttach')
const file = document.getElementById('chatFile')
if (attach && file) {
attach.addEventListener('click', () => file.click())
file.addEventListener('change', () => {
if (file.files[0]) addMessage(`Attached: ${file.files[0].name}`)
file.value = ''
})
}
const compose = document.getElementById('chatNew')
if (compose) compose.addEventListener('click', () => input.focus())
app.chatApi = { addMessage, thread, log }
}
const initConversations = () => {
const app = document.querySelector('.chat-app')
const items = document.querySelectorAll('.chat-conv')
const toggle = document.getElementById('chatListToggle')
if (!app || !app.chatApi || !items.length) return
const { addMessage, thread, log } = app.chatApi
const jackHtml = thread.innerHTML
const setList = (open) => {
app.classList.toggle('is-list-open', open)
if (toggle) toggle.setAttribute('aria-expanded', String(open))
}
if (toggle) toggle.addEventListener('click', () => setList(!app.classList.contains('is-list-open')))
items.forEach((btn) => btn.addEventListener('click', () => {
items.forEach((b) => { b.classList.remove('is-active'); b.removeAttribute('aria-current') })
btn.classList.add('is-active')
btn.setAttribute('aria-current', 'true')
btn.querySelector('.badge')?.remove()
document.getElementById('chatHeadName').textContent = btn.dataset.name
document.getElementById('chatHeadSub').textContent = btn.dataset.statusLabel
document.getElementById('chatHeadImg').src = btn.dataset.img
document.getElementById('chatHeadStatus').className = `avatar-status status-${btn.dataset.status}`
const messages = THREADS[btn.dataset.thread]
if (messages === null) thread.innerHTML = jackHtml
else {
thread.replaceChildren()
messages.forEach(([dir, text, stamp]) => addMessage(text, dir, stamp, btn.dataset.img))
}
log.scrollTop = log.scrollHeight
setList(false)
}))
}
const initFilter = () => {
const menu = document.getElementById('chatFilterMenu')
const label = document.getElementById('chatFilterLabel')
const empty = document.getElementById('chatEmpty')
const rows = document.querySelectorAll('.chat-item')
if (!menu || !label || !empty) return
menu.querySelectorAll('[data-filter]').forEach((btn) => btn.addEventListener('click', () => {
const mode = btn.dataset.filter
menu.querySelectorAll('.menu-item').forEach((b) => b.classList.toggle('selected', b === btn))
label.textContent = btn.textContent
let shown = 0
rows.forEach((row) => {
const conv = row.querySelector('.chat-conv')
const match = mode === 'all' || (mode === 'unread' && conv.querySelector('.badge')) || (mode === 'groups' && conv.dataset.type === 'group')
row.hidden = !match
if (match) shown++
})
empty.hidden = shown > 0
}))
}
initChat()
initConversations()
initFilter()
Built on Bootstrap 6.0.0-alpha.1 (ES modules, no jQuery). Load Bootstrap through an import map and run the JavaScript as type="module" — the downloaded file is already set up that way. Alpha releases can still change class names.
Built on Bootstrap 5.3.8 with plain JavaScript — no jQuery. Supports light and dark mode via data-bs-theme.