# Chat Messages

> Chat view with grouped bubbles, sent/read receipts, a typing indicator and auto-scroll.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/chat-messages
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/chat-messages.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/chat-messages-demo.tsx"
"use client"

import * as React from "react"

import { ChatMessages, type ChatMessage } from "@/components/chat-messages"

const seed: ChatMessage[] = [
  { id: "1", from: "them", text: "Hey! Did the build go through?", time: "10:41" },
  { id: "2", from: "them", text: "Staging looked red a minute ago.", time: "10:41" },
  { id: "3", from: "me", text: "Yep, it was a flaky test. Re-ran it.", time: "10:42", status: "read" },
  { id: "4", from: "me", text: "All green now.", time: "10:42", status: "read" },
]

const replies = ["Perfect, shipping it.", "Thanks for checking!", "Nice. Coffee?"]

export default function ChatMessagesDemo() {
  const [messages, setMessages] = React.useState(seed)
  const [typing, setTyping] = React.useState(false)
  const counter = React.useRef(0)

  const send = (text: string) => {
    const n = counter.current++
    setMessages((m) => [...m, { id: `me-${n}`, from: "me", text, time: "now", status: "sent" }])
    setTyping(true)
    window.setTimeout(() => {
      setTyping(false)
      setMessages((m) => [
        ...m.map((x) => (x.status === "sent" ? { ...x, status: "read" as const } : x)),
        { id: `them-${n}`, from: "them", text: replies[n % replies.length], time: "now" },
      ])
    }, 1400)
  }

  return (
    <div className="w-full max-w-sm">
      <ChatMessages messages={messages} partner="Alex" typing={typing} onSend={send} />
    </div>
  )
}
```

## Source

```tsx title="components/chat-messages.tsx"
"use client"

import * as React from "react"
import { Check, CheckCheck, SendHorizontal } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ChatMessage {
  id: string
  from: "me" | "them"
  text: string
  /** Pre-formatted, such as "10:42" */
  time: string
  status?: "sent" | "read"
}

export interface ChatMessagesProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  messages: ChatMessage[]
  partner?: string
  /** Show the animated typing bubble */
  typing?: boolean
  onSend?: (text: string) => void
}

export function ChatMessages({ messages, partner = "Them", typing = false, onSend, className, ...props }: ChatMessagesProps) {
  const [draft, setDraft] = React.useState("")
  const scroller = React.useRef<HTMLDivElement>(null)

  React.useLayoutEffect(() => {
    const el = scroller.current
    if (el) el.scrollTop = el.scrollHeight
  }, [messages.length, typing])

  return (
    <div {...props} className={cn("flex h-112 w-full flex-col overflow-hidden rounded-2xl border bg-card", className)}>
      <header className="flex items-center gap-3 border-b px-4 py-3">
        <span aria-hidden className="flex size-8 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
          {partner.slice(0, 1)}
        </span>
        <div className="leading-tight">
          <p className="text-sm font-semibold">{partner}</p>
          <p className="text-xs text-muted-foreground">{typing ? "typing…" : "online"}</p>
        </div>
      </header>

      <div ref={scroller} role="log" aria-live="polite" aria-label={`Conversation with ${partner}`} className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-4">
        {messages.map((message, index) => {
          const mine = message.from === "me"
          const prev = messages[index - 1]
          const next = messages[index + 1]
          const firstOfRun = prev?.from !== message.from
          const lastOfRun = next?.from !== message.from
          return (
            <div key={message.id} className={cn("flex flex-col", mine ? "items-end" : "items-start", firstOfRun && index > 0 && "mt-3")}>
              <div
                className={cn(
                  "max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-snug",
                  mine ? "bg-primary text-primary-foreground" : "bg-muted",
                  mine ? (firstOfRun ? "" : "rounded-tr-md") + (lastOfRun ? "" : " rounded-br-md") : (firstOfRun ? "" : "rounded-tl-md") + (lastOfRun ? "" : " rounded-bl-md"),
                )}
              >
                {message.text}
              </div>
              {lastOfRun && (
                <span className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
                  {message.time}
                  {mine && (message.status === "read" ? <CheckCheck className="size-3.5 text-primary" aria-label="Read" /> : <Check className="size-3.5" aria-label="Sent" />)}
                </span>
              )}
            </div>
          )
        })}
        {typing && (
          <div className="mt-2 flex w-fit items-center gap-1 rounded-2xl bg-muted px-4 py-3" aria-label={`${partner} is typing`}>
            {[0, 150, 300].map((delay) => (
              <span key={delay} className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60" style={{ animationDelay: `${delay}ms` }} />
            ))}
          </div>
        )}
      </div>

      <form
        className="flex items-center gap-2 border-t p-3"
        onSubmit={(event) => {
          event.preventDefault()
          const text = draft.trim()
          if (!text) return
          onSend?.(text)
          setDraft("")
        }}
      >
        <input
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          aria-label="Message"
          placeholder="Write a message…"
          className="h-10 min-w-0 flex-1 rounded-full border border-input bg-background px-4 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30"
        />
        <button
          type="submit"
          aria-label="Send"
          disabled={!draft.trim()}
          className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-40"
        >
          <SendHorizontal className="size-4" />
        </button>
      </form>
    </div>
  )
}
```
