/ Examples
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>