Chat Conversation List — Free HTML CSS JS Snippet

Chat Conversation List · Navigation · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Unread badges + counter
Per-chat count pills plus a header total computed with reduce; opening a chat clears its badge and updates the total.
Presence dots
Online indicators cut out of the avatar corner with a white border ring, readable on any avatar colour.
Typing indicator previews
A typing flag swaps the last message for three staggered bouncing dots, ready to drive from WebSocket events.
Live search
Case-insensitive filtering across names and message text on every keystroke, with an explicit empty state.
Ellipsis-safe layout
min-width: 0 on the flex column lets long names and previews truncate with text-overflow instead of breaking the row.
Three-channel unread styling
Badge, bold dark preview, and accent timestamp all hang off one .unread class.
Accessible list semantics
Items are real buttons in a role=list container — keyboard focusable and screen-reader enumerable.
Original-index mapping
Filtered rows keep their master-array index so active state and mark-as-read survive searching.

About this UI Snippet

Chat Conversation List — Messenger Sidebar with Unread Badges, Presence, and Live Search

Screenshot of the Chat Conversation List snippet rendered live

Before a user ever sees a chat window, they see the conversation list — the inbox sidebar that WhatsApp, Messenger, Slack, and iMessage all share: avatar with a presence dot, name, last-message preview, relative timestamp, and an unread badge. This component builds that full pattern in HTML, CSS, and vanilla JavaScript, including live search across names and messages, an animated typing indicator in the preview line, and click-to-open behaviour that marks the conversation read.

The two-row item anatomy

Each conversation is a real <button> (so it is keyboard-focusable and clickable for free) laid out with flexbox: a fixed 44px avatar column and a flexible main column holding two rows. Row one puts the name and timestamp at opposite ends with justify-content: space-between and align-items: baseline so the small <time> sits on the name's text baseline. Row two does the same with the message preview and the unread badge. The critical detail is min-width: 0 on the flexible column — without it, a long message preview refuses to shrink and blows the layout open; with it, text-overflow: ellipsis can truncate both the name and the preview cleanly.

Unread state in three visual channels

An unread conversation communicates through three reinforcing signals: the count badge (a min-width pill so "3" and "12" both render round), the preview text darkening to near-black and turning semibold, and the timestamp switching to the accent colour. All three hang off a single .unread class, so marking a chat read is one class removal. Clicking an item zeroes its unread count and re-renders — the badge disappears, the preview relaxes to grey, and the header's "new" counter (a reduce over all unread counts) drops accordingly.

Presence dots and the typing indicator

The online dot is absolutely positioned on the avatar's bottom-right corner with a 2.5px white ring (border) so it reads cleanly against any avatar colour — the same cut-out technique used by every messenger. When a contact is typing, the preview row swaps the last message for three bouncing dots animated with staggered translateY keyframes at 0/0.15/0.3s delays, identical in spirit to the standalone typing indicator. Because typing is just a flag on the data, your WebSocket handler only has to flip typing and re-render.

Live search over names and previews

The search input filters on every input event with a case-insensitive includes against both the contact name and the last message, so searching "mockups" finds the Design Team chat even though the query is not in the name. The filter maps items with their original index first ({ c, i }) so the active highlight and click handling still reference the master CHATS array, not the filtered copy — a subtle bug this structure avoids. An empty result renders an explicit "No conversations found" state.

Relative timestamps and group chats

Timestamps are short relative labels (2m, 1h, Tue) exactly as messengers display them — in production, derive these from message dates with Intl.RelativeTimeFormat and fall back to weekday names past 48 hours. Group chats work with no special casing: prefix the sender inside the message string ("Theo: uploaded the new mockups") and the preview handles it.

Customisation

Feed CHATS from your API, flip online, typing, and unread from your realtime events, and attach navigation in the click handler — pair it with a chat UI on the right for a complete messaging screen. Swap initials avatars for <img> tags, and adjust the accent #6366f1 to your brand.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of tracing the index-mapping by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why render() maps each chat to an { c, i } pair before filtering, and what specific bug would appear if the click handler used the filtered array's position instead of that original index. The same assistant can help you optimize it — ask whether min-width: 0 is really required on the flex column for the ellipsis truncation to work, and what visually breaks if it's removed. It's also a great partner for extending the list: ask it to add swipe-to-archive gestures on mobile, group conversations into pinned versus regular sections, or wire the typing flag and unread counts to real WebSocket events instead of static demo data. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "messenger-style conversation list" sidebar in plain HTML, CSS, and JavaScript — no framework, no library.

Requirements:
- Render every conversation row as a real button element (not a div with a click handler) from a single JavaScript array of chat objects, each with a name, last message, relative timestamp, accent color, online boolean, unread count, and typing boolean.
- Each row's flexible text column must correctly truncate a long name and a long message preview with an ellipsis rather than breaking the row's layout or pushing the timestamp/badge out of view — get the specific CSS property right that allows a flex child to shrink below its content's intrinsic width.
- An unread conversation must be visually distinguished through at least three coordinated signals hanging off one shared state class: a numeric badge, a bolder/darker message preview color, and an accent-colored timestamp.
- A presence dot absolutely positioned on the avatar's corner with a border matching the page background so it reads as a clean cutout regardless of the avatar's own color.
- When a contact is typing, swap that row's message preview for three dots animated with a staggered bouncing keyframe (different animation-delay per dot) instead of showing stale message text.
- A live search input that filters the list on every keystroke by matching the query case-insensitively against both the name and the message text, showing an explicit empty state when nothing matches.
- Critically: when filtering produces a shorter, reordered array, clicking a row (to mark it read) must correctly identify and mutate the right entry in the original unfiltered data array — not accidentally use the filtered array's position as if it were the original index.

Step by step

How to Use

  1. 1
    Paste the HTML, CSS, and JSA messages panel renders with seven conversations — unread badges, green presence dots, one contact mid-typing, and a "new" counter in the header.
  2. 2
    Click a conversationIt highlights as active, its unread badge clears, the preview text relaxes from bold, and the header's new-message count drops.
  3. 3
    Watch the typing indicatorJonas Weber's row shows three bouncing dots in place of the last message — the flag-driven typing state.
  4. 4
    Search the listType in the search box — filtering matches both contact names and message text live, with an empty state when nothing matches.
  5. 5
    Swap in your dataReplace CHATS with your API payload and flip online/typing/unread from WebSocket events; render(search.value) refreshes the list.
  6. 6
    Wire up navigationIn the click handler, route to the selected thread — pair with a chat window component for a full messenger screen.

Real-world uses

Common Use Cases

Messaging app sidebars
The inbox pane of a chat product — pair with a chat UI window and an emoji picker.
Support-agent dashboards
List open tickets as conversations with unread counts; combine with an ai chat interface for bot handoff.
Team collaboration tools
Channel and DM lists with presence — sits naturally beside a team presence list.
Dating and social apps
Match inboxes where the typing indicator and online dots drive re-engagement.
Marketplace buyer-seller chat
Per-order threads with unread badges so sellers can triage at a glance.
Learning the inbox pattern
A reference for flexbox truncation, presence dots, and unread-state styling done the way real messengers do it.

Got questions?

Frequently Asked Questions

Flex items default to min-width: auto, which means they refuse to shrink below their content's intrinsic width — a long message preview would push the timestamp and badge out of the card instead of truncating. Setting min-width: 0 on the .ccl-main column allows it to shrink, which lets white-space: nowrap + overflow: hidden + text-overflow: ellipsis actually clip the text. This is the single most common bug when building chat lists.

The click handler reads the item's data-i index into the master CHATS array, sets that chat's unread to 0, stores it as activeIndex, and re-renders. The .unread class (badge, bold preview, accent timestamp) disappears because the class is derived from the data on every render, and the header counter — a reduce over all unread values — recomputes automatically. State lives in the data, never in the DOM.

Typing is just a boolean on each chat object. When your WebSocket receives a "user is typing" event, set that chat's typing = true and call render(); on the stop event (or a 3-second timeout, which is how most messengers expire it), set it back and re-render. The preview row conditionally renders the three-dot animation instead of the message text whenever the flag is set.

Filtering creates a new, shorter array, but active highlighting and mark-as-read must mutate the master CHATS list. The render maps each chat to { c, i } before filtering, so every rendered row carries its true index in data-i. Without this, clicking the second visible result while searching would mark the wrong conversation read — an off-by-mapping bug this pattern eliminates.

Hold chats, the search query, and activeId in state and derive the filtered list with useMemo / computed / a getter — filter by id rather than index and the mapping concern disappears. Each item becomes a component receiving the chat object; the click handler dispatches a "mark read" state update. The CSS (presence dot ring, ellipsis rules, typing keyframes) ports unchanged, and realtime events simply become state setters.