reversegroups
Messaging
Chat UI with reverse: true and
withGroups for date headers. Auto-scroll on new
messages, variable heights via DOM measurement.
R
Radiooooo Lounge
Loading messages…
Source
// Messaging — Chat UI with reverse mode + date headers
// Demonstrates reverse: true, withGroups, DOM measurement,
// auto-scroll, incoming messages, send input.
import { vlist, withGroups } from "vlist";
import {
getChatUser,
pickMessage,
CHAT_NAMES,
CHAT_COLORS,
} from "../../src/data/messages.js";
import { createStats } from "../stats.js";
import "./controls.js";
// =============================================================================
// Constants
// =============================================================================
const TOTAL_MESSAGES = 5000;
const DATE_HEADER_HEIGHT = 28;
const DEFAULT_MSG_HEIGHT = 56;
const SELF_USER = { name: "You", color: "#667eea", initials: "YO" };
// =============================================================================
// Date labels — Jan 1 to today
// =============================================================================
const generateDateLabels = () => {
const labels = [];
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYear = new Date(now.getFullYear(), 0, 1);
const daysSinceStart = Math.floor(
(today - startOfYear) / (1000 * 60 * 60 * 24),
);
for (let i = daysSinceStart; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
if (i === 0) labels.push("Today");
else if (i === 1) labels.push("Yesterday");
else
labels.push(
date.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
);
}
return labels;
};
const DATE_LABELS = generateDateLabels();
// =============================================================================
// Data — generate deterministic messages
// =============================================================================
const generateMessage = (index) => {
const user = getChatUser(index % CHAT_NAMES.length);
const text = pickMessage(index);
const dayIndex = Math.floor((index / TOTAL_MESSAGES) * DATE_LABELS.length);
const dateSection = Math.min(dayIndex, DATE_LABELS.length - 1);
const messagesPerDay = TOTAL_MESSAGES / DATE_LABELS.length;
const indexInDay = index % messagesPerDay;
const hour = 8 + Math.floor((indexInDay / messagesPerDay) * 14);
const minute = (index * 7) % 60;
return {
id: `msg-${index}`,
text,
user: user.name,
color: user.color,
initials: user.initials,
isSelf: false,
time: `${hour}:${String(minute).padStart(2, "0")}`,
height: 0,
dateSection,
};
};
export let currentItems = Array.from({ length: TOTAL_MESSAGES }, (_, i) =>
generateMessage(i),
);
// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================
export let currentHeaderMode = "sticky"; // "sticky" | "inline" | "off"
export let autoMessages = true;
export let list = null;
let sentCounter = 1;
let autoTimer = null;
export function setCurrentHeaderMode(v) {
currentHeaderMode = v;
}
export function setAutoMessages(v) {
autoMessages = v;
if (v) scheduleNextMessage();
else if (autoTimer) {
clearTimeout(autoTimer);
autoTimer = null;
}
}
// =============================================================================
// Templates
// =============================================================================
const renderMessage = (item) => {
const selfClass = item.isSelf ? " msg--self" : "";
return `
<div class="msg${selfClass}">
<div class="msg__avatar" style="background:${item.color}">${item.initials}</div>
<div class="msg__bubble">
<div class="msg__header">
<div class="msg__name">${item.user}</div>
<div class="msg__time">${item.time}</div>
</div>
<div class="msg__text">${item.text}</div>
</div>
</div>
`;
};
const renderDateHeader = (dateLabel) => {
const el = document.createElement("div");
el.className = "date-sep";
el.innerHTML = `
<span class="date-sep__line"></span>
<span class="date-sep__text">${dateLabel}</span>
<span class="date-sep__line"></span>
`;
return el;
};
// =============================================================================
// DOM Measurement
// =============================================================================
const contentCache = new Map();
const measureHeights = (items, width) => {
const measurer = document.createElement("div");
measurer.style.cssText = `
position: absolute;
visibility: hidden;
width: ${width}px;
pointer-events: none;
`;
document.body.appendChild(measurer);
for (const item of items) {
const key = `${item.id}-${width}`;
if (contentCache.has(key)) {
item.height = contentCache.get(key);
continue;
}
measurer.innerHTML = renderMessage(item);
const measured = measurer.firstElementChild.offsetHeight;
item.height = measured;
contentCache.set(key, measured);
}
document.body.removeChild(measurer);
};
const getMeasureWidth = () => {
const viewport = container.querySelector(".vlist-viewport");
return viewport ? viewport.clientWidth : container.clientWidth;
};
// =============================================================================
// Stats — shared footer
// =============================================================================
export const stats = createStats({
getList: () => list,
getTotal: () => currentItems.length,
getItemHeight: () => DEFAULT_MSG_HEIGHT,
container: "#list-container",
});
// =============================================================================
// DOM references
// =============================================================================
const container = document.getElementById("list-container");
const statusEl = document.getElementById("channel-status");
const inputEl = document.getElementById("message-input");
const sendBtn = document.getElementById("send-btn");
const newMessagesBar = document.getElementById("new-messages-bar");
const newMessagesBtn = document.getElementById("new-messages-btn");
const newMessagesCount = document.getElementById("new-messages-count");
// =============================================================================
// New messages notification
// =============================================================================
let unreadCount = 0;
let currentRange = { start: 0, end: 0 };
const isAtBottom = () => {
if (!list) return true;
return currentRange && currentRange.end >= list.total - 5;
};
const showNewMessages = (count) => {
unreadCount = count;
newMessagesCount.textContent =
count === 1 ? "1 new message" : `${count} new messages`;
newMessagesBar.style.display = "block";
};
const hideNewMessages = () => {
unreadCount = 0;
newMessagesBar.style.display = "none";
};
newMessagesBtn.addEventListener("click", () => {
if (!list) return;
list.scrollToIndex(list.total - 1, {
align: "start",
behavior: "smooth",
duration: 600,
});
hideNewMessages();
});
// =============================================================================
// Create / Recreate list
// =============================================================================
let firstVisibleIndex = 0;
export function createList() {
if (list) {
list.destroy();
list = null;
}
container.innerHTML = "";
// Measure all items before creating
measureHeights(currentItems, 600);
const builder = vlist({
container: "#list-container",
ariaLabel: "Chat messages",
reverse: true,
item: {
height: (index) => {
const item = currentItems[index];
return (item && item.height) || DEFAULT_MSG_HEIGHT;
},
template: (item) => {
const el = document.createElement("div");
el.innerHTML = renderMessage(item);
return el.firstElementChild;
},
},
items: currentItems,
});
if (currentHeaderMode !== "off") {
builder.use(
withGroups({
getGroupForIndex: (index) => {
const item = currentItems[index];
return item ? DATE_LABELS[item.dateSection] : "Unknown";
},
headerHeight: DATE_HEADER_HEIGHT,
headerTemplate: renderDateHeader,
sticky: currentHeaderMode === "sticky",
}),
);
}
list = builder.build();
// Wire events
list.on("scroll", stats.scheduleUpdate);
list.on("range:change", ({ range }) => {
currentRange = range;
firstVisibleIndex = range.start;
stats.scheduleUpdate();
// Hide notification when user scrolls to bottom
if (isAtBottom() && unreadCount > 0) {
hideNewMessages();
}
});
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
// Restore scroll position
if (firstVisibleIndex > 0) {
list.scrollToIndex(firstVisibleIndex, "start");
}
statusEl.textContent = `${currentItems.length.toLocaleString()} messages`;
stats.update();
updateContext();
}
// =============================================================================
// Send message
// =============================================================================
const sendMessage = () => {
if (!list) return;
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = "";
const now = new Date();
const time = now.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
const msg = {
id: `sent-${sentCounter++}`,
text,
user: SELF_USER.name,
color: SELF_USER.color,
initials: SELF_USER.initials,
isSelf: true,
time,
height: DEFAULT_MSG_HEIGHT,
dateSection: DATE_LABELS.length - 1,
};
measureHeights([msg], getMeasureWidth());
currentItems = [...currentItems, msg];
list.appendItems([msg]);
// Always scroll to bottom when sending
list.scrollToIndex(list.total - 1, {
align: "start",
behavior: "smooth",
duration: 300,
});
statusEl.textContent = `${currentItems.length.toLocaleString()} messages`;
stats.update();
};
sendBtn.addEventListener("click", sendMessage);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// =============================================================================
// Auto-generate incoming messages
// =============================================================================
const generateRandomMessage = () => {
if (!list || !autoMessages) return;
const now = new Date();
const time = now.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
const userIdx = Math.floor(Math.random() * CHAT_NAMES.length);
const user = getChatUser(userIdx);
const text = pickMessage(Date.now());
const msg = {
id: `auto-${Date.now()}`,
text,
user: user.name,
color: user.color,
initials: user.initials,
isSelf: false,
time,
height: DEFAULT_MSG_HEIGHT,
dateSection: DATE_LABELS.length - 1,
};
measureHeights([msg], getMeasureWidth());
currentItems = [...currentItems, msg];
list.appendItems([msg]);
const atBottom = isAtBottom();
if (!atBottom) {
showNewMessages(unreadCount + 1);
} else {
list.scrollToIndex(list.total - 1, {
align: "start",
behavior: "smooth",
duration: 300,
});
}
statusEl.textContent = `${currentItems.length.toLocaleString()} messages`;
stats.update();
scheduleNextMessage();
};
const scheduleNextMessage = () => {
if (!autoMessages) return;
const delay = 2000 + Math.random() * 6000;
autoTimer = setTimeout(generateRandomMessage, delay);
};
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const ftHeaders = document.getElementById("ft-headers");
export function updateContext() {
ftHeaders.textContent = currentHeaderMode;
}
// =============================================================================
// Initialise
// =============================================================================
createList();
scheduleNextMessage();
/* Messaging — example styles */
.split-layout {
height: 600px;
}
/* ============================================================================
Chat Wrapper — header, list, input
============================================================================ */
.chat-wrapper {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--vlist-bg, #fff);
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
/* ============================================================================
Chat Header
============================================================================ */
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--accent);
color: white;
}
.chat-header__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.chat-header__info {
flex: 1;
min-width: 0;
}
.chat-header__name {
font-weight: 600;
font-size: 15px;
}
.chat-header__status {
font-size: 12px;
opacity: 0.8;
}
/* ============================================================================
List container
============================================================================ */
#list-container {
flex: 1;
background: var(--vlist-bg);
overflow: hidden;
border-radius: 0;
border: none;
}
/* ============================================================================
Chat Input
============================================================================ */
.chat-input {
display: flex;
gap: 8px;
padding: 10px 12px;
background: var(--vlist-bg, #fff);
}
.chat-input__field {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--vlist-border, #ddd);
border-radius: 20px;
font-size: 14px;
font-family: inherit;
outline: none;
background: var(--vlist-bg-hover, #f5f5f5);
color: var(--vlist-text, #333);
transition: border-color 0.15s ease;
}
.chat-input__field::placeholder {
color: var(--vlist-text-muted, #999);
}
.chat-input__field:focus {
border-color: var(--accent);
}
.chat-input__send {
padding: 10px 20px;
border: none;
border-radius: 20px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition:
background 0.15s ease,
transform 0.1s ease;
flex-shrink: 0;
}
.chat-input__send:hover {
opacity: 0.9;
}
.chat-input__send:active {
transform: scale(0.96);
}
/* ============================================================================
New Messages Notification
============================================================================ */
.new-messages-bar {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
pointer-events: none;
}
.new-messages-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 20px;
background: var(--accent);
color: white;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
pointer-events: auto;
}
.new-messages-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.new-messages-btn:active {
transform: translateY(0);
}
/* ============================================================================
Split Layout Overrides
============================================================================ */
.split-main {
display: flex;
flex-direction: column;
}
/* ============================================================================
vlist overrides
============================================================================ */
#list-container .vlist {
border-radius: 0;
border: none;
}
/* ============================================================================
vlist item overrides
============================================================================ */
#list-container .vlist--grouped .vlist-item[data-id^="__group_header_"] {
background-color: transparent;
}
.vlist-sticky-header {
border-bottom: 0;
background-color: var(--vlist-bg);
}
#list-container
.vlist--grouped
.vlist-item[data-id^="__group_header_"]
.date-sep {
width: 100%;
}
#list-container .vlist-item {
padding: 0;
border-bottom: none;
cursor: default;
}
#list-container .vlist-item:hover {
background-color: transparent;
}
/* ============================================================================
Date Separator
============================================================================ */
.date-sep {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 20px;
}
.date-sep__line {
flex: 1;
height: 1px;
background: var(--vlist-border);
}
.date-sep__text {
font-size: 11px;
font-weight: 600;
color: var(--vlist-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
padding: 4px 12px;
background: var(--vlist-bg-hover, #f0f0f0);
border-radius: 10px;
}
/* ============================================================================
Message Bubbles
============================================================================ */
.msg {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 3px 16px;
width: 100%;
}
.msg--self {
flex-direction: row-reverse;
}
.msg__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
}
.msg__bubble {
max-width: 70%;
padding: 8px 12px;
border-radius: 16px;
position: relative;
min-width: 0;
}
/* Other people's messages */
.msg:not(.msg--self) .msg__bubble {
background: var(--vlist-bg-hover, #f0f0f0);
border-bottom-left-radius: 4px;
}
/* Self messages */
.msg--self .msg__bubble {
background: var(--accent);
color: white;
border-bottom-right-radius: 4px;
}
.msg__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 2px;
}
.msg--self .msg__header {
flex-direction: row-reverse;
}
.msg__name {
font-size: 11px;
font-weight: 600;
opacity: 0.7;
}
.msg--self .msg__name {
color: rgba(255, 255, 255, 0.8);
}
.msg__time {
font-size: 10px;
opacity: 0.5;
flex-shrink: 0;
}
.msg__text {
font-size: 14px;
line-height: 1.4;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 900px) {
.chat-wrapper {
max-height: 500px;
}
}
<div class="container">
<header>
<h1>Messaging</h1>
<p class="description">
Chat UI with <code>reverse: true</code> and
<code>withGroups</code> for date headers. Auto-scroll on new
messages, variable heights via DOM measurement.
</p>
</header>
<div class="split-layout">
<div class="split-main split-main--full">
<div class="chat-wrapper">
<div class="chat-header">
<div class="chat-header__avatar">R</div>
<div class="chat-header__info">
<div class="chat-header__name">Radiooooo Lounge</div>
<div class="chat-header__status" id="channel-status">
Loading messages…
</div>
</div>
</div>
<div id="list-container"></div>
<div
id="new-messages-bar"
class="new-messages-bar"
style="display: none"
>
<button id="new-messages-btn" class="new-messages-btn">
<span id="new-messages-count">1 new message</span>
<i class="icon icon--down"></i>
</button>
</div>
<div class="chat-input">
<input
type="text"
id="message-input"
class="chat-input__field"
placeholder="Type a message…"
autocomplete="off"
/>
<button id="send-btn" class="chat-input__send">
<i class="icon icon--send"></i>
</button>
</div>
</div>
</div>
<aside class="split-panel">
<!-- Headers -->
<section class="panel-section">
<h3 class="panel-title">Headers</h3>
<div class="panel-row">
<div class="panel-segmented" id="header-mode">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-mode="sticky"
>
Sticky
</button>
<button class="panel-segmented__btn" data-mode="inline">
Inline
</button>
<button class="panel-segmented__btn" data-mode="off">
Off
</button>
</div>
</div>
</section>
<!-- Auto Messages -->
<section class="panel-section">
<h3 class="panel-title">Auto Messages</h3>
<div class="panel-row">
<label class="panel-label">Incoming</label>
<div class="panel-segmented" id="auto-mode">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-auto="true"
>
On
</button>
<button class="panel-segmented__btn" data-auto="false">
Off
</button>
</div>
</div>
</section>
<!-- Navigation -->
<section class="panel-section">
<h3 class="panel-title">Navigation</h3>
<div class="panel-row">
<div class="panel-btn-group">
<button
id="btn-top"
class="panel-btn panel-btn--icon"
title="Oldest"
>
<i class="icon icon--up"></i>
</button>
<button
id="btn-bottom"
class="panel-btn panel-btn--icon"
title="Latest"
>
<i class="icon icon--down"></i>
</button>
</div>
</div>
</section>
</aside>
</div>
<footer class="example-footer" id="example-footer">
<div class="example-footer__left">
<span class="example-footer__stat">
<strong id="ft-progress">0%</strong>
</span>
<span class="example-footer__stat">
<span id="ft-velocity">0.00</span> /
<strong id="ft-velocity-avg">0.00</strong>
<span class="example-footer__unit">px/ms</span>
</span>
<span class="example-footer__stat">
<span id="ft-dom">0</span> /
<strong id="ft-total">0</strong>
<span class="example-footer__unit">items</span>
</span>
</div>
<div class="example-footer__right">
<span class="example-footer__stat"> reverse </span>
<span class="example-footer__stat">
<strong id="ft-headers">sticky</strong>
</span>
</div>
</footer>
</div>