# Build a Modern Chat UI with Just CSS and JavaScript

> Source: <https://dev.to/_artclick/build-a-modern-chat-ui-with-just-css-and-javascript-34ah>
> Published: 2026-08-03 10:10:41+00:00

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:

``` js
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.
