{"slug": "build-a-modern-chat-ui-with-just-css-and-javascript", "title": "Build a Modern Chat UI with Just CSS and JavaScript", "summary": "A developer detailed how to build a modern chat interface using only CSS and JavaScript, covering semantic markup, bubble styling, a CSS-only typing indicator, smart auto-scrolling, and accessibility features like aria-live. The approach avoids frameworks and focuses on five core design ideas, with code examples for each.", "body_md": "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.\n\nThis 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.\n\nBefore 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 `<div>`\n\ns:\n\nEverything below is really just these five ideas turned into CSS and JS.\n\nKeep it semantic — a list of messages, each one a list item, grouped inside a labeled region:\n\n```\n<section class=\"chat-window\" aria-label=\"Conversation\">\n  <ul class=\"chat-log\" id=\"chatLog\" aria-live=\"polite\">\n    <li class=\"message message--received\">\n      <img class=\"avatar\" src=\"avatar.jpg\" alt=\"\">\n      <div class=\"bubble\">\n        <p>Hey, are we still on for the call at 3?</p>\n        <time datetime=\"2026-08-03T14:58\">2:58 PM</time>\n      </div>\n    </li>\n    <li class=\"message message--sent\">\n      <div class=\"bubble\">\n        <p>Yep, I'll send the doc beforehand.</p>\n        <time datetime=\"2026-08-03T14:59\">2:59 PM</time>\n      </div>\n    </li>\n  </ul>\n</section>\n```\n\nNote the `aria-live=\"polite\"`\n\non the log itself — that one attribute does most of the accessibility work for announcing new messages, and it's easy to forget entirely.\n\nThe core trick is flipping `flex-direction`\n\nfor sent messages and shaving one corner off each bubble to fake a \"tail\" pointing toward its sender:\n\n```\n.chat-log {\n  list-style: none;\n  display: flex;\n  flex-direction: column;\n  gap: 0.75rem;\n  padding: 1rem;\n  margin: 0;\n}\n\n.message {\n  display: flex;\n  align-items: flex-end;\n  gap: 0.5rem;\n  max-width: 75%;\n}\n\n.message--sent {\n  align-self: flex-end;\n  flex-direction: row-reverse;\n}\n\n.bubble {\n  background: var(--bubble-received, #eef0f3);\n  color: var(--text-color, #1a1a1a);\n  padding: 0.6rem 0.9rem;\n  border-radius: 1.1rem;\n}\n\n.message--sent .bubble {\n  background: var(--bubble-sent, #4b7bec);\n  color: #fff;\n  border-bottom-right-radius: 0.3rem;\n}\n\n.message--received .bubble {\n  border-bottom-left-radius: 0.3rem;\n}\n\n.bubble time {\n  display: block;\n  margin-top: 0.25rem;\n  font-size: 0.7rem;\n  opacity: 0.65;\n}\n```\n\n`max-width: 75%`\n\nkeeps long messages from stretching edge-to-edge, which is what makes a chat window read as \"a conversation\" instead of \"a document.\"\n\nThree dots, staggered animation delays, done with pure CSS:\n\n```\n.typing {\n  display: flex;\n  gap: 4px;\n  padding: 0.6rem 0.9rem;\n}\n\n.typing span {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: currentColor;\n  opacity: 0.4;\n  animation: typing-bounce 1.2s infinite ease-in-out;\n}\n\n.typing span:nth-child(2) { animation-delay: 0.15s; }\n.typing span:nth-child(3) { animation-delay: 0.3s; }\n\n@keyframes typing-bounce {\n  0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }\n  30% { transform: translateY(-4px); opacity: 1; }\n}\n```\n\nBecause this animates `transform`\n\nand `opacity`\n\nrather than `top`\n\n/`height`\n\n, it runs on the compositor thread — smooth even on a busy page, and it won't trigger layout on every frame.\n\nDon't force-scroll to the bottom on every new message — only do it if the user was already near the bottom:\n\n``` js\nconst chatLog = document.getElementById('chatLog');\n\nfunction isNearBottom() {\n  const threshold = 120; // px\n  return chatLog.scrollHeight - chatLog.scrollTop - chatLog.clientHeight < threshold;\n}\n\nfunction appendMessage(html) {\n  const shouldStick = isNearBottom();\n  chatLog.insertAdjacentHTML('beforeend', html);\n  if (shouldStick) {\n    chatLog.scrollTop = chatLog.scrollHeight;\n  }\n}\n```\n\nIf 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.\n\nWhen 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:\n\n```\nfunction shouldGroup(current, previous) {\n  if (!previous || current.sender !== previous.sender) return false;\n  const gapMs = new Date(current.timestamp) - new Date(previous.timestamp);\n  return gapMs < 2 * 60 * 1000; // 2 minutes\n}\n.message--grouped {\n  margin-top: -0.4rem;\n}\n\n.message--grouped .avatar,\n.message--grouped time {\n  visibility: hidden;\n}\n```\n\nHiding rather than removing keeps the layout width consistent — the avatar still occupies its column, it just isn't drawn.\n\nDefine the handful of colors that actually change as custom properties once, then swap them under a `data-theme`\n\nattribute:\n\n```\n:root {\n  --bg: #ffffff;\n  --text-color: #1a1a1a;\n  --bubble-received: #eef0f3;\n  --bubble-sent: #4b7bec;\n}\n\n[data-theme=\"dark\"] {\n  --bg: #14171c;\n  --text-color: #e8eaed;\n  --bubble-received: #262b33;\n  --bubble-sent: #5b8dfc;\n}\n```\n\nToggle `data-theme`\n\non `<html>`\n\nor `<body>`\n\nwith a few lines of JS, and optionally default it from `prefers-color-scheme`\n\non first load so the chat window matches the user's system setting before they've touched anything.\n\n`aria-live=\"polite\"`\n\n```\n@media (prefers-reduced-motion: reduce) {\n  .typing span {\n    animation: none;\n    opacity: 0.6;\n  }\n}\n```\n\nA chat log is one of the easiest UIs to accidentally make slow, because it's the one place where the DOM keeps growing indefinitely.\n\n`DocumentFragment`\n\nand insert it in one operation instead of appending message-by-message, which forces a reflow on every single insert.`scroll`\n\nevent fires far more often than you need it to.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.\n\n**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.", "url": "https://wpnews.pro/news/build-a-modern-chat-ui-with-just-css-and-javascript", "canonical_source": "https://dev.to/_artclick/build-a-modern-chat-ui-with-just-css-and-javascript-34ah", "published_at": "2026-08-03 10:10:41+00:00", "updated_at": "2026-08-03 10:45:40.877719+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/build-a-modern-chat-ui-with-just-css-and-javascript", "markdown": "https://wpnews.pro/news/build-a-modern-chat-ui-with-just-css-and-javascript.md", "text": "https://wpnews.pro/news/build-a-modern-chat-ui-with-just-css-and-javascript.txt", "jsonld": "https://wpnews.pro/news/build-a-modern-chat-ui-with-just-css-and-javascript.jsonld"}}