cd /news/developer-tools/build-a-modern-chat-ui-with-just-css… · home topics developer-tools article
[ARTICLE · art-84535] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Build a Modern Chat UI with Just CSS and JavaScript

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.

read4 min views1 publishedAug 3, 2026

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.

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 <div>

s:

Everything below is really just these five ideas turned into CSS and JS.

Keep it semantic — a list of messages, each one a list item, grouped inside a labeled region:

<section class="chat-window" aria-label="Conversation">
  <ul class="chat-log" id="chatLog" aria-live="polite">
    <li class="message message--received">
      <img class="avatar" src="avatar.jpg" alt="">
      <div class="bubble">
        <p>Hey, are we still on for the call at 3?</p>
        <time datetime="2026-08-03T14:58">2:58 PM</time>
      </div>
    </li>
    <li class="message message--sent">
      <div class="bubble">
        <p>Yep, I'll send the doc beforehand.</p>
        <time datetime="2026-08-03T14:59">2:59 PM</time>
      </div>
    </li>
  </ul>
</section>

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.

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."

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.

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.

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.

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 <html>

or <body>

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.

aria-live="polite"

@media (prefers-reduced-motion: reduce) {
  .typing span {
    animation: none;
    opacity: 0.6;
  }
}

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.

DocumentFragment

and insert it in one operation instead of appending message-by-message, which forces a reflow on every single insert.scroll

event 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.

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.

── more in #developer-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-a-modern-chat-…] indexed:0 read:4min 2026-08-03 ·