Chat Message Bubbles — HTML CSS JS Snippet
Chat Message Bubbles with Read Receipts · Cards · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="thread" id="thread">
<div class="day-divider"><span>Today</span></div>
<div class="msg-row them">
<div class="avatar">JN</div>
<div class="bubble-group">
<div class="bubble">Hey! Did you get a chance to look at the designs?</div>
<div class="bubble">No rush, just checking in</div>
<div class="meta">9:14 AM</div>
</div>
</div>
<div class="msg-row me">
<div class="bubble-group">
<div class="bubble">Yep, looking now 👀</div>
<div class="meta">Sent <span class="tick tick-read">✓✓</span> · 9:16 AM</div>
</div>
</div>
<div class="msg-row them">
<div class="avatar">JN</div>
<div class="bubble-group">
<div class="bubble">Take your time, no deadline on this one</div>
<div class="meta">9:17 AM</div>
</div>
</div>
<div class="msg-row me">
<div class="bubble-group">
<div class="bubble">Sounds good, I'll send feedback by EOD</div>
<div class="meta">Delivered <span class="tick">✓</span> · 9:18 AM</div>
</div>
</div>
<div class="typing-row" id="typingRow" hidden>
<div class="avatar">JN</div>
<div class="bubble typing-bubble"><span></span><span></span><span></span></div>
</div>
</div>
<form class="composer" onsubmit="return sendMsg(event)">
<input type="text" id="msgInput" placeholder="Type a message..." autocomplete="off">
<button type="submit" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/></svg>
</button>
</form>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f0f2f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
.demo { width: 100%; max-width: 380px; height: 520px; background: #fff; border-radius: 16px; box-shadow: 0 12px 32px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; }
.thread { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.day-divider { text-align: center; margin: 4px 0 10px; }
.day-divider span { background: #eef0f3; color: #8a8f98; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 999px; }
.msg-row { display: flex; gap: 8px; align-items: flex-end; max-width: 82%; }
.msg-row.them { align-self: flex-start; }
.msg-row.me { align-self: flex-end; flex-direction: row-reverse; }
.avatar { width: 26px; height: 26px; border-radius: 50%; background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.bubble-group { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.msg-row.me .bubble-group { align-items: flex-end; }
.bubble { padding: 9px 13px; border-radius: 16px; font-size: 13.5px; line-height: 1.4; word-wrap: break-word; }
.msg-row.them .bubble { background: #f0f1f3; color: #1f2937; border-bottom-left-radius: 4px; }
.msg-row.me .bubble { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; }
.msg-row.them .bubble-group .bubble:not(:first-child) { border-top-left-radius: 16px; }
.meta { font-size: 10.5px; color: #9ca3af; padding: 0 4px; }
.tick { color: #9ca3af; }
.tick-read { color: #34c759; }
.typing-row { display: flex; gap: 8px; align-items: flex-end; }
.typing-bubble { background: #f0f1f3; border-bottom-left-radius: 4px; padding: 11px 14px; display: flex; gap: 4px; }
.typing-bubble span { width: 6px; height: 6px; border-radius: 50%; background: #9ca3af; animation: bounce 1.2s infinite; }
.typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
.typing-bubble span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-4px); opacity: 1; } }
.composer { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #eef0f3; }
.composer input { flex: 1; border: 1px solid #e5e7eb; border-radius: 20px; padding: 9px 14px; font-size: 13.5px; outline: none; }
.composer input:focus { border-color: #2563eb; }
.composer button { width: 36px; height: 36px; border: none; border-radius: 50%; background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
.composer button:hover { background: #1d4ed8; }var thread = document.getElementById('thread');
var typingRow = document.getElementById('typingRow');
function scrollToBottom() {
thread.scrollTop = thread.scrollHeight;
}
function addMessage(text, mine) {
var row = document.createElement('div');
row.className = 'msg-row ' + (mine ? 'me' : 'them');
var time = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
row.innerHTML = (mine ? '' : '<div class="avatar">JN</div>') +
'<div class="bubble-group"><div class="bubble"></div><div class="meta">' +
(mine ? 'Sent <span class="tick">\u2713</span> · ' + time : time) + '</div></div>';
row.querySelector('.bubble').textContent = text;
thread.insertBefore(row, typingRow);
scrollToBottom();
return row;
}
function sendMsg(e) {
e.preventDefault();
var input = document.getElementById('msgInput');
var text = input.value.trim();
if (!text) return false;
addMessage(text, true);
input.value = '';
simulateReply();
return false;
}
function simulateReply() {
setTimeout(function() {
typingRow.hidden = false;
scrollToBottom();
setTimeout(function() {
typingRow.hidden = true;
addMessage('Got it, thanks!', false);
}, 1400);
}, 500);
}
scrollToBottom();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Chat Message Bubbles — Grouped Threads, Read Receipts & Typing Indicator

A chat interface is more than styled speech bubbles — the details that make one feel real are message grouping, delivery status, and a typing indicator that appears and disappears at the right moments. This snippet builds all three on top of plain flexbox, matching the visual language of iMessage and WhatsApp.
Bubble tails via asymmetric border-radius
Rather than an SVG or pseudo-element tail, each bubble uses border-radius: 16px with one corner overridden: incoming bubbles get border-bottom-left-radius: 4px, outgoing bubbles get border-bottom-right-radius: 4px. The sharp corner sits nearest the avatar, which is what visually "points" the bubble at its sender without any extra markup.
Grouping consecutive messages
When the same sender posts multiple messages in a row, only the first carries the full rounded top corners — a CSS rule targets .bubble:not(:first-child) within a group to restore the top-left radius on any bubble after the first, since the group's first bubble is the only one that should have a soft top. Combined with the shared .bubble-group wrapper only showing one timestamp per group instead of one per bubble, this is what makes rapid-fire messages read as one thought instead of a wall of separate boxes.
Read receipts
Each outgoing message's .meta line renders a status word ("Sent" or "Delivered") plus a checkmark. A single check in gray means delivered; a double check in green (.tick-read) means read. This is purely a CSS class toggle — in a real app, flip .tick to .tick-read when your backend's read-receipt event for that message arrives, typically over a WebSocket.
Day dividers
A centered pill (.day-divider) breaks the thread into date groups, matching the convention in every major chat app. In a real implementation, insert one whenever a message's date differs from the previous message's date, computed by comparing toDateString() on consecutive timestamps.
The typing indicator
Three dots inside a bubble-shaped container animate with a shared @keyframes bounce, offset by animation-delay of 0s, 0.15s, and 0.3s so they ripple rather than bounce in unison — the same staggering technique used by loading-dot spinners elsewhere in this library. The row is toggled via the hidden attribute rather than being added and removed from the DOM, so no layout thrash occurs each time it appears.
Sending flow
sendMsg() prevents the form's default submission, builds a new .msg-row via addMessage(), and inserts it with insertBefore(row, typingRow) — always right before the typing indicator's row rather than at the end of the thread, so the typing bubble (when shown) stays visually last. scrollToBottom() runs after every insertion by setting thread.scrollTop = thread.scrollHeight, keeping the newest message in view the way every chat app auto-scrolls.
Simulated reply for the demo
simulateReply() shows the typing indicator after a short delay, waits, then hides it and inserts a reply — standing in for a real WebSocket or polling connection. In production, the typing indicator's visibility should be driven by a "user is typing" event from your backend, not a fixed timeout, and the actual reply should come from your message stream rather than a hardcoded string.
XSS safety
Message text is written using .textContent, never innerHTML, on the dynamically created bubble — the only innerHTML usage builds the surrounding structural markup (avatar, meta), never the user-authored message body, which is the detail that keeps a chat UI safe when real user input flows through it.
See also the chat conversation list for the inbox view this thread would open from, and the AI chat interface for a variant built around a single assistant rather than a peer-to-peer conversation.
Step by step
How to Use
- 1Copy the thread and composer markupThe .thread scroll container holds .msg-row groups (them/me variants) plus a hidden .typing-row, and a .composer form sits below it for input.
- 2Wire sendMsg to your backendReplace the demo's local addMessage call with an API request or WebSocket emit, and only render the message once your backend confirms receipt if you want a "sending" intermediate state.
- 3Drive read receipts from real eventsToggle a message's .tick class to .tick-read when your backend's read-receipt event for that specific message id arrives, rather than on a timer.
- 4Show typing status from the other userToggle typingRow.hidden based on a "user is typing" socket event from the other participant, not a fixed setTimeout as the demo does.
- 5Insert day dividers dynamicallyBefore rendering a new message, compare its date to the previous message's date with toDateString() and insert a .day-divider element when they differ.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Replace the local addMessage() call in sendMsg() with an emit to your WebSocket or a POST to your API, then render the message when the server confirms it (or optimistically, then reconcile). Listen for incoming-message, typing, and read-receipt events to drive the other three dynamic pieces.
Before inserting each new message, compare new Date(message.timestamp).toDateString() to the previous message's date string; if they differ, insert a .day-divider element before the message row.
Listen for a "typing" event from your backend (commonly debounced on their keystrokes) and toggle typingRow.hidden = false, then hide it again after a short timeout or when their message actually arrives — do not drive it from a fixed local timer in production.
Yes — every message body is set via element.textContent, not innerHTML, so any HTML or script tags a user types are rendered as literal visible text rather than executed markup.
Open the Export menu on the snippet page for a React component that keeps messages in a useState array and maps over them, a React + Tailwind version, a Vue 3 SFC, or an Angular standalone component — all preserve the grouping, read-receipt, and typing-indicator logic.