Dev.to · 6 min read

Build a Modern Chat UI with Just CSS and JavaScript

Build a Modern Chat UI with Just CSS and JavaScript

Chat interfaces are everywhere now — support widgets, team tools, AI assistants, in-app messaging. They all share the same underlying patterns, and none of them actually require a framework or a component library to get right. A well-built chat UI is mostly a handful of CSS decisions and a couple of small, deliberate JavaScript behaviors. This walks through building one from scratch: the markup, the bubble styling, a typing indicator, smart auto-scrolling, message grouping, dark mode, and the accessibility details that are easy to miss. What actually makes a chat UI feel right Before touching code, it helps to name the specific things that separate a chat UI that feels considered from one that feels like a plain list of s: Sent vs. received is instantly visually distinct — usually via alignment and color, not color alone. Consecutive messages from the same person are visually grouped, not repeated with a full avatar/timestamp every time. New messages don't fight the user for scroll position. If someone has scrolled up to reread something, a new message shouldn't yank them back down. A typing indicator communicates "still here" without being distracting. Screen readers get told about new messages without interrupting whatever the user is currently doing. Everything below is really just these five ideas turned into CSS and JS. The markup Keep it semantic — a list of messages, each one a list item, grouped inside a labeled region: Hey, are we still on for the call at 3? 2:58 PM Yep, I'll send the doc beforehand. 2:59 PM Note the aria-live="polite" on the log itself — that one attribute does most of the accessibility work for announcing new messages, and it's easy to forget entirely. Styling the bubbles The core trick is flipping flex-direction for sent messages and shaving one corner off each bubble to fake a "tail" pointing toward its sender: .chat-log { list-style: none; display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; margin: 0; } .message { display: flex; align-items: flex-end; gap: 0.5rem; max-width: 75%; } .message--sent { align-self: flex-end; flex-direction: row-reverse; } .bubble { background: var(--bubble-received, #eef0f3); color: var(--text-color, #1a1a1a); padding: 0.6rem 0.9rem; border-radius: 1.1rem; } .message--sent .bubble { background: var(--bubble-sent, #4b7bec); color: #fff; border-bottom-right-radius: 0.3rem; } .message--received .bubble { border-bottom-left-radius: 0.3rem; } .bubble time { display: block; margin-top: 0.25rem; font-size: 0.7rem; opacity: 0.65; } max-width: 75% keeps long messages from stretching edge-to-edge, which is what makes a chat window read as "a conversation" instead of "a document." A typing indicator that doesn't feel janky Three dots, staggered animation delays, done with pure CSS: .typing { display: flex; gap: 4px; padding: 0.6rem 0.9rem; } .typing span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.4; animation: typing-bounce 1.2s infinite ease-in-out; } .typing span:nth-child(2) { animation-delay: 0.15s; } .typing span:nth-child(3) { animation-delay: 0.3s; } @keyframes typing-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-4px); opacity: 1; } } Because this animates transform and opacity rather than top/height, it runs on the compositor thread — smooth even on a busy page, and it won't trigger layout on every frame. Auto-scroll — but only when the user would want it Don't force-scroll to the bottom on every new message — only do it if the user was already near the bottom: const chatLog = document.getElementById('chatLog'); function isNearBottom() { const threshold = 120; // px return chatLog.scrollHeight - chatLog.scrollTop - chatLog.clientHeight < threshold; } function appendMessage(html) { const shouldStick = isNearBottom(); chatLog.insertAdjacentHTML('beforeend', html); if (shouldStick) { chatLog.scrollTop = chatLog.scrollHeight; } } If someone has scrolled up to reread earlier messages, this leaves them exactly where they are instead of yanking them back down when a new message arrives. Grouping consecutive messages When the same person sends several messages in a row, repeating the avatar and timestamp on every single one adds visual noise. Group them if the sender matches and the gap is small: function shouldGroup(current, previous) { if (!previous || current.sender !== previous.sender) return false; const gapMs = new Date(current.timestamp) - new Date(previous.timestamp); return gapMs < 2 * 60 * 1000; // 2 minutes } .message--grouped { margin-top: -0.4rem; } .message--grouped .avatar, .message--grouped time { visibility: hidden; } Hiding rather than removing keeps the layout width consistent — the avatar still occupies its column, it just isn't drawn. Dark mode, without duplicating every rule Define the handful of colors that actually change as custom properties once, then swap them under a data-theme attribute: :root { --bg: #ffffff; --text-color: #1a1a1a; --bubble-received: #eef0f3; --bubble-sent: #4b7bec; } [data-theme="dark"] { --bg: #14171c; --text-color: #e8eaed; --bubble-received: #262b33; --bubble-sent: #5b8dfc; } Toggle data-theme on or with a few lines of JS, and optionally default it from prefers-color-scheme on first load so the chat window matches the user's system setting before they've touched anything. The accessibility details that get skipped aria-live="polite" on the message log (already in the markup above) announces new messages without interrupting whatever the screen reader is currently reading. Don't rely on color alone to distinguish sent from received — alignment already does most of that work here, which is exactly why it matters more than color. Don't steal focus. A new incoming message shouldn't move keyboard focus away from wherever the user currently is, especially not away from an open text input. Respect reduced motion for the typing indicator: @media (prefers-reduced-motion: reduce) { .typing span { animation: none; opacity: 0.6; } } Keeping it fast as history grows A chat log is one of the easiest UIs to accidentally make slow, because it's the one place where the DOM keeps growing indefinitely. Don't keep unlimited history in the DOM. Virtualize (render only messages near the viewport) or paginate older messages behind a "load earlier messages" action. Batch DOM writes when inserting many messages at once — build a DocumentFragment and insert it in one operation instead of appending message-by-message, which forces a reflow on every single insert. Debounce scroll listeners if you're using scroll position to trigger loading older history — a raw scroll event fires far more often than you need it to. Wrapping up None of this requires a framework, a state management library, or a UI kit. A chat interface that feels genuinely well-made comes down to a small set of deliberate choices: clear sent/received distinction, message grouping, scroll that respects what the user is doing, an accessible live region, and a bit of care around performance as history grows. Get those right and the rest is just visual polish on top. At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability. Whether you're starting from scratch or improving an existing platform, we'd love to help. https://artclickdev.com/

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News