CSS 및 JavaScript만으로 최신 채팅 UI 구축

작성자

카테고리:

← 피드로
DEV Community · Artclick · 2026-08-03 개발(SW)
Cover image for Build a Modern Chat UI with Just CSS and JavaScript

Artclick

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

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

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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; }
}

Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

.message--grouped {
  margin-top: -0.4rem;
}

.message--grouped .avatar,
.message--grouped time {
  visibility: hidden;
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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.

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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/

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다