More Navigation Snippets
Chat Conversation List — Free HTML CSS JS Snippet
Chat Conversation List · Navigation · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Chat Conversation List — Messenger Sidebar with Unread Badges, Presence, and Live Search

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:
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
- 1Paste 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.
- 2Click a conversationIt highlights as active, its unread badge clears, the preview text relaxes from bold, and the header's new-message count drops.
- 3Watch the typing indicatorJonas Weber's row shows three bouncing dots in place of the last message — the flag-driven typing state.
- 4Search the listType in the search box — filtering matches both contact names and message text live, with an empty state when nothing matches.
- 5Swap in your dataReplace CHATS with your API payload and flip online/typing/unread from WebSocket events; render(search.value) refreshes the list.
- 6Wire 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
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.