A chat surface lives or dies on a handful of small, easy-to-skip details: whether the scroll position jumps to the newest message without a visible lurch, whether a bubble reads as "from them" or "from you" at a glance, whether a typing indicator ever gets stuck on screen after a dropped event. None of that is hard, exactly — but it's the kind of detail that's tedious to get right from scratch every time a product grows a messaging surface, which is most products eventually.
Below are ten free, self-contained messaging snippets covering the jobs a real chat product needs: the core bubble UI with Enter-to-send, a messenger-style inbox sidebar with unread badges and live search, a staggered-dot typing indicator with an idle auto-hide safety net, a WhatsApp-style voice note with a scrubbable waveform, a Facebook-style emoji reaction picker, an @mention autocomplete built on the Selection and Range APIs, a fully nested comment thread with Reddit-style voting, a Zoom-style video call grid with active-speaker glow and spotlight pinning, a floating support chat widget with a spring-open animation, and a team presence list sorted by who's actually around. Every one is a live, interactive preview — try them right here in the article — and each exports to React, Vue, Angular or Tailwind in one click from its snippet page. All ten are plain HTML, CSS and vanilla JavaScript, with no dependencies and no CDN scripts.
What these ten get right
- State lives in one place, not scattered across DOM reads. A single
myReactionvariable, a singlepinnedIndex, a singletypingflag on a data object — every visual change is derived from one source of truth and rebuilt with a render function, instead of five separate style writes that can drift out of sync with each other. - Auto-scroll and auto-hide both have a safety net.
scrollTop = scrollHeightafter every append keeps the newest message visible without a scroll library, and the typing indicator's idle timer hides itself automatically if a "stopped typing" event never arrives — a dropped WebSocket event should never leave stale UI stuck on screen forever. - Bubble direction is one asymmetric border-radius corner, not a class-based icon. Outgoing bubbles square off the corner facing the sender's edge of the screen; incoming bubbles square off the opposite one. It's the same three-line trick every messaging app from iMessage to Slack relies on to make direction readable without a label.
- Nothing waits on
Math.random()when it needs to be stable. The voice message waveform is generated once from a seeded pseudo-random function and cached — never regenerated per frame — because a real recording's shape doesn't change between plays, and a demo one shouldn't either. - Filtered views still point back at the real data. The conversation list's search keeps each row's original array index through the filter, and the comment thread's reply and vote handlers use scoped
:scope >queries so a nested structure never accidentally mutates the wrong node.
1. Chat UI
The foundational messaging pattern: a header with an online-status avatar, a scrollable bubble list with incoming and outgoing styles, and an input row with Enter-to-send.
How it works: sendMsg() reads and trims the input, does nothing if it's empty, and otherwise builds a div.msg.outgoing with the text and a toLocaleTimeString stamp, appends it to #msgs, and sets msgs.scrollTop = msgs.scrollHeight so the newest message is always in view — no scroll library, just reading the container's own maximum scroll position after every append. Outgoing and incoming bubbles use opposite asymmetric corners (16px 4px 16px 16px versus 4px 16px 16px 16px), the small squared-off "tail" corner that lets a glance tell direction without a label. The input's onkeydown checks for Enter and calls the exact same sendMsg() the button uses, so there's no duplicated send logic between the two paths.
Best for: AI chatbot interfaces, customer support widgets, and any direct-message prototype — this is the bubble pattern every DM surface from iMessage to Slack shares. Tip: the simulated reply is a bare setTimeout; replace it with a WebSocket onmessage handler that appends .msg.incoming divs from real server data.
Grab the code: Chat UI
2. Chat Conversation List
The messenger-style inbox sidebar that sits beside a chat window: avatar with a presence dot, name, last-message preview, relative timestamp, unread badge, and live search.
How it works: each conversation is a real <button> laid out with a fixed avatar column and a flexible text column — the critical detail is min-width: 0 on that flexible column, since flex items default to refusing to shrink below their content's width, which is what breaks ellipsis truncation on a long preview. An unread conversation communicates through three coordinated signals hanging off one .unread class: a count badge, a bolder dark preview, and an accent-colored timestamp — clicking a row zeroes its unread count and all three disappear together. Search filters on every keystroke with a case-insensitive match against both name and message, but critically maps each row to { c, i } before filtering, so the click handler still mutates the correct entry in the master array even when the visible list is a shorter, reordered subset.
Best for: the inbox pane of any messaging product, support-agent dashboards listing open tickets, and team collaboration tools with channel and DM lists. Tip: feed the typing flag straight from your WebSocket presence channel — it's a boolean on the data, so a "user is typing" event just flips it and re-renders; nothing else about the row needs to change.
Grab the code: Chat Conversation List
3. Typing Indicator
The three bouncing dots that mean "someone is composing a reply" — built from one shared keyframe, staggered start times, and an idle timer that hides itself if the stop event never arrives.
How it works: all three dots share a single @keyframes that lifts a dot and brightens it before settling — the travelling-wave look comes entirely from animation-delay offsets of 0s, 0.18s and 0.36s, so at any instant the three dots sit at different points in the same cycle. That's the general delay-stagger technique for any sequential dot or bar loader, one animation instead of three. The more important piece is the state: showTyping() reveals the bubble and resets a 4-second idle timer on every call, so if a "stopped typing" WebSocket event is ever dropped, the dots vanish on their own instead of sitting there forever. Hiding animates opacity and collapses height rather than an abrupt display: none, so the message list closes the gap smoothly instead of jumping.
Best for: any chat, AI assistant, or live-support interface where reassurance that "a reply is coming" matters — pair it with the Chat UI above for a complete thread. Tip: call showTyping() — exposed on window in the demo — every time a typing event arrives over your socket; the idle timer is only a safety net, not the primary trigger.
Grab the code: Typing Indicator
4. Voice Message Bubble
A WhatsApp-style voice note: a play button, a waveform of forty-six bars that recolors in sync with playback, and full click-to-seek and drag-to-scrub support.
How it works: the bar heights come from generateBarHeights(), called exactly once at load and cached — never recalculated inside the render loop, which would make the waveform flicker to a new random shape every frame. Each height combines a slow sine wave for gentle rise-and-fall with a small seeded jitter from a linear congruential generator rather than Math.random(), so the same waveform shape renders on every mount, the same way a real recording's shape never changes between plays. Playback is driven by requestAnimationFrame accumulating real delta-time into an elapsed variable rather than setInterval, which stays accurate even under dropped frames or background-tab throttling. elapsed / DURATION becomes a bar count that gets the .played class, and dragging uses the unified Pointer Events API with setPointerCapture so a fast scrub keeps tracking even once the pointer leaves the waveform's bounds — the exact same updateUi() call path handles both automatic playback and manual seeking, so the two can never fall out of sync.
Best for: any chat product's voice-note bubble, voicemail or podcast-clip preview cards, and marketing pages demoing a messaging feature without shipping a real audio file. Tip: wiring this to real audio only means swapping the elapsed-time source — read an actual <audio> element's currentTime in a timeupdate listener instead of the manual dt accumulation; the recoloring and drag-to-seek logic stay untouched.
Grab the code: Voice Message Bubble
5. Emoji Reaction Bar
The Facebook-style reaction picker: hover or tap to reveal six emoji in a spring-up popover, each with a CSS-only tooltip, feeding an overlapping count cluster.
How it works: the popover shows on :hover for pointer devices, but since touch screens have no hover state, the trigger also has an onclick that toggles an .open class revealing the same popover — stopPropagation keeps a document-level outside-click listener from immediately re-closing what the tap just opened. Each emoji's name label is a CSS-only tooltip built from content: attr(data-label) on a ::after pseudo-element, no tooltip markup or library required. The single-reaction logic is one line: myReaction = (myReaction === emoji) ? null : emoji — clicking your current reaction removes it, clicking a different one switches. The overlapping count cluster is pure CSS, negative margin-left with white borders faking the stacked-coin look, and when your reaction isn't already among the top three shown, it's unshifted to the front with a highlighted ring and a spring-in scale animation.
Best for: social feeds and post cards, comment threads, and chat bubbles where nuance beyond a plain like matters. Tip: the emoji buttons are real <button> elements already keyboard-reachable — add an aria-label matching each data-label and open the popover on focus too, since an emoji alone isn't descriptive to a screen reader.
Grab the code: Emoji Reaction Bar
6. Mention Autocomplete
A working @mention system inside a real contenteditable: type @, get a filtered popup with keyboard navigation, and land a non-editable mention chip at the caret.
How it works: on every input event, getQueryBeforeCaret() reads the current text node up to the caret offset and calls lastIndexOf('@') to find the most recent trigger — if any whitespace sits between the @ and the caret, there's no active query and the popup closes, which is how typing a space mid-mention dismisses the dropdown correctly. Arrow keys move a selectedIdx and re-render the filtered list; Enter or a click calls insertMention(), which selects from the @ to the caret, deletes it, and inserts a span with contentEditable = "false" — the standard technique that makes a mention chip behave as one atomic unit the cursor jumps over rather than edits inside. A trailing non-breaking space gives the cursor a landing spot immediately after the chip so the next keystroke starts plain text instead of extending the mention.
Best for: comment systems on project-management and issue-tracking tools, real-time chat, and any collaborative editor that needs to tag people inline. Tip: for production reliability across browsers, swap the raw contenteditable for a proper rich-text library (ProseMirror, Tiptap, Slate) — this snippet demonstrates the exact Selection/Range API calls and insertion technique any of those implementations builds on.
Grab the code: Mention Autocomplete
7. Comment Thread
A fully nested discussion thread: self-similar reply structure at any depth, Reddit-style up/down voting with no double-counting, top/newest sorting, and an auto-growing composer.
How it works: every comment is a .comment element containing its own .replies container, which can hold more .comment elements — because the markup is self-similar at every depth, the same functions work regardless of nesting level. Voting stores a base score plus a separate tri-state value (0, 1, or −1) rather than incrementing a raw counter directly, so the displayed count is always base + state: clicking the active arrow again returns to neutral, clicking the opposite arrow switches directly between states, and repeated clicks can never drift or double-count. Reply insertion and voting both use :scope > selectors to target only a comment's own immediate children — critical in a recursive tree, where a plain querySelector could silently match a nested descendant's box instead. Sorting reorders top-level comments by reading data-votes or data-time attributes and re-appending the existing DOM nodes in new order — appendChild on an existing node moves it rather than cloning, so every open reply box and attached handler survives a re-sort.
Best for: blog and article comment sections, Reddit- or Hacker News-style forums, and product feedback boards where vote sorting should surface the best replies. Tip: past three or four nesting levels, thread indentation gets cramped on mobile — cap the cumulative left padding or flatten deeper replies to the same indentation as their parent with a "replying to @user" prefix, the way Reddit handles it.
Grab the code: Comment Thread
8. Video Call Grid
A Zoom-style participant grid: an auto-fit tile layout, animated active-speaker highlighting, mute badges, and click-to-pin spotlight mode with a filmstrip of the rest.
How it works: the whole participant layout is one rule, grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)) — six people become a 3×2 grid on desktop and reflow to a single column on narrow screens with zero media queries or JavaScript layout math. Clicking a tile sets pinnedIndex and re-renders with a single spotlight class on the grid, which does three things purely in CSS: collapses to one column, hides every tile except the pinned one, and reveals a scrollable filmstrip of the rest — the pinned tile also widens from a 4:3 to a 16:9 aspect-ratio, matching how real meeting apps enlarge a presented feed. A setInterval stands in for real audio levels, promoting a random unmuted participant to .speaking every 2.2 seconds — that class lights a green border, a glow, and a three-bar equalizer built from staggered scaleY keyframes, the same trick used by the voice message bubble above. Camera-off tiles render generated initials over a per-person gradient rather than an image, which is exactly how production call UIs handle a participant with no video.
Best for: WebRTC video conferencing products, virtual classrooms pinning the teacher while students sit in the filmstrip, and webinar "program view" layouts. Tip: the whole component is one render() function over a PEOPLE array — porting it onto live data means replacing the demo interval with your SDK's real audioLevel events and swapping the initials avatar for a <video> element bound to each participant's MediaStream; every other part of the UI works unchanged.
Grab the code: Video Call Grid
9. Floating Chat Support Widget
The Intercom-style floating support button: a spring-open chat window, a typing indicator before the canned reply, quick-reply chips, and an unread badge.
How it works: the chat window's closed state is scale(.8) translateY(20px) with opacity: 0 and transform-origin: bottom right; opening transitions to scale(1) translateY(0) using cubic-bezier(.34,1.56,.64,1) — an easing curve that overshoots slightly past 1 before settling, which is what makes the window feel like it "pops" open from the trigger button rather than just fading in. Sending a message shows the typing indicator (three dots on a staggered animation-delay bounce, the same technique as the standalone typing indicator snippet), removes it after a randomized 1.2–1.8 second delay, and appends a reply cycling through a fixed REPLIES array. Quick-reply chips populate the input and immediately send on click, then hide permanently for the session — showing them only before the first message keeps them from cluttering an active conversation. The unread badge uses a transform: scale(0) transition to disappear the moment the window opens, rather than an abrupt visibility toggle.
Best for: customer support entry points on SaaS products, conversational lead capture on marketing pages, and proactive onboarding nudges for new users. Tip: replace the REPLIES mock with a real fetch or WebSocket call inside sendMessage() — show the typing indicator while the request is pending and remove it only once the actual response arrives, rather than on the fixed demo timeout.
Grab the code: Floating Chat Support Widget
10. Team Presence List
A searchable roster of teammates, sorted so the people who are actually around float to the top — active, away and do-not-disturb ahead of offline.
How it works: a STATUS_RANK lookup (online: 0, away: 1, dnd: 1, offline: 2) drives a sort() on every render, so the list order isn't hardcoded — anyone who comes online moves to the top automatically the next time state changes. The presence dot is absolutely positioned on the avatar's corner with a white border ring, the same cut-out technique the video call grid and chat conversation list both use so it reads cleanly against any avatar color. Search filters on every keystroke against both name and role with a case-insensitive indexOf check, re-running the same sort-and-render pipeline so a filtered result stays correctly ordered rather than just narrowed.
Best for: team collaboration tools showing who's online next to a channel list, internal directories, and any dashboard where "who's around right now" is a real, recurring question. Tip: drive the status field straight from your presence channel's heartbeat events rather than a static array — the rank-based sort means the UI reorders itself correctly the instant the underlying data changes, no extra logic required.
Grab the code: Team Presence List
How to drop these into your project
- Open any snippet and hit View / Edit Code to see the HTML, CSS and JS in separate tabs, then paste the HTML into your markup, the CSS into your stylesheet, and the JS before your closing
</body>tag — or use the one-click export to React, Vue, Angular or Tailwind right from the snippet page. All ten are dependency-free, so there's nothing else to install. - Wire real-time events to a flag or class, not five inline styles. The typing indicator's idle timer, the presence dot's status, the video grid's speaking class, and the reaction bar's
myReactionvariable are all single values a WebSocket handler can flip — let CSS descendant selectors do the rest, don't hand-set styles from JavaScript. - Keep auto-scroll and auto-hide as safety nets, not decoration.
scrollTop = scrollHeightafter every append and a 4-second idle timeout on the typing indicator both exist because real-time events get dropped — a chat surface that assumes every event always arrives will eventually show stale or stuck UI. - Respect the recursive and index-mapping details when you port to a framework. The comment thread's
:scope >queries and the conversation list's original-index mapping through a filter both exist to prevent a specific class of bug — model them as nested data and scoped lookups in React or Vue rather than flattening the structure and losing that guarantee. - Match bubble direction to your theme, not just your accent color. The asymmetric corner-radius pattern (a squared corner on the side facing the speaker's edge) is what makes a bubble read as incoming or outgoing at a glance — keep that shape intact even if you restyle everything else.
- Replace every simulated backend before shipping. The chat widget's canned
REPLIESarray, the video grid's random-speaker interval, and the voice bubble's fixedDURATIONare explicitly demo stand-ins — each snippet's FAQ section spells out exactly what to swap for a real WebSocket, SDK event, or<audio>element.
Final thought
Messaging UI looks simple from the outside — bubbles, a dot that pulses, a list that scrolls — right up until you have to build it and discover how much of the feel comes from details that are easy to skip: a scroll position that never jumps, a typing indicator that can't get stuck, a filtered list that still points at the right row. These ten cover the jobs a real messaging surface needs, from the first bubble to a full video call grid, and every one is small enough to read end to end in a few minutes.
Try them, retune the timings, and export to your framework of choice in one click. Browse the full collection at FWD Tools UI Snippets — it's free and runs entirely in your browser.
