Message Thread
A compact chat: your message slides up from the composer, shows Sent then Read, and a typing indicator precedes replies.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/message-thread.json'use client'
import { useEffect, useRef, useState } from 'react'
import './message-thread.css'
export interface Msg { id: string; from: 'me' | 'them'; text: string }
export interface MessageThreadProps {
messages: Msg[]
onSend?: (text: string) => Promise<string | void>
name?: string
receipts?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Chat thread with send animation, receipts and typing indicator. */
export function MessageThread({ messages, onSend, name = 'Sam', receipts = true, fill = 'dark', className }: MessageThreadProps) {
const [list, setList] = useState(messages)
const [text, setText] = useState('')
const [status, setStatus] = useState<'' | 'Sending' | 'Sent' | 'Read'>('')
const [typing, setTyping] = useState(false)
const log = useRef<HTMLDivElement>(null)
useEffect(() => { log.current?.scrollTo({ top: log.current.scrollHeight, behavior: 'smooth' }) }, [list, typing, status])
const send = async () => {
const t = text.trim()
if (!t) return
setText('')
setList((l) => [...l, { id: String(Date.now()), from: 'me', text: t }])
setStatus('Sending')
await new Promise((r) => setTimeout(r, 400))
setStatus('Sent')
const reply = onSend ? onSend(t) : Promise.resolve(undefined)
await new Promise((r) => setTimeout(r, 700))
setStatus('Read')
setTyping(true)
const r = await reply
await new Promise((res) => setTimeout(res, 1200))
setTyping(false)
if (r) { setList((l) => [...l, { id: `${Date.now()}r`, from: 'them', text: r }]); setStatus('') }
}
const lastMe = list.map((m) => m.from).lastIndexOf('me')
return (
<div className={['sd-msg', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-msg__top"><span className="sd-msg__av" aria-hidden>{name[0]}</span><span>{name}<small>{typing ? 'typing…' : 'Active now'}</small></span></div>
<div ref={log} className="sd-msg__log" role="log" aria-label={`Conversation with ${name}`}>
{list.map((m, i) => (
<div key={m.id} style={{ display: 'contents' }}>
<div className="sd-msg__b" data-me={m.from === 'me' || undefined} data-gap={(i > 0 && list[i - 1].from !== m.from) || undefined}>{m.text}</div>
{receipts && i === lastMe && status && i === list.length - 1 && <span key={status} className="sd-msg__st">{status}</span>}
</div>
))}
{typing && <div className="sd-msg__typing" aria-label={`${name} is typing`}><i /><i /><i /></div>}
</div>
<form className="sd-msg__comp" onSubmit={(e) => { e.preventDefault(); send() }}>
<textarea className="sd-msg__ta" rows={1} aria-label="Message" placeholder="Message" value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
<button type="submit" className="sd-msg__send" aria-label="Send" disabled={!text.trim()}><svg viewBox="0 0 16 16" aria-hidden><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" /></svg></button>
</form>
</div>
)
}.sd-msg { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-msg[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-msg button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-msg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-msg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-msg { width: min(100%, 320px); height: 400px; display: flex; flex-direction: column; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-msg__top { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 13.5px; font-weight: 600; }
.sd-msg__av { width: 28px; height: 28px; border-radius: 99px; background: var(--soft); display: grid; place-items: center; font-size: 11.5px; }
.sd-msg__top small { display: block; font-weight: 400; font-size: 11.5px; color: var(--mute); }
.sd-msg__log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 3px; }
.sd-msg__b { max-width: 78%; padding: 8px 12px; border-radius: 18px; font-size: 14px; line-height: 1.35; white-space: pre-wrap; word-wrap: break-word; animation: sd-msg-in 280ms cubic-bezier(0.2, 1.2, 0.3, 1) both; }
.sd-msg__b[data-me] { align-self: flex-end; background: var(--strong); color: var(--on); border-bottom-right-radius: 6px; transform-origin: bottom right; }
.sd-msg__b:not([data-me]) { align-self: flex-start; background: var(--soft); border-bottom-left-radius: 6px; transform-origin: bottom left; }
.sd-msg__b[data-gap] { margin-top: 8px; }
@keyframes sd-msg-in { from { opacity: 0; transform: translateY(10px) scale(0.92); } }
.sd-msg__st { align-self: flex-end; font-size: 11px; color: var(--mute); margin-top: 2px; animation: sd-msg-fade 200ms ease; }
@keyframes sd-msg-fade { from { opacity: 0; } }
.sd-msg__typing { align-self: flex-start; display: flex; gap: 4px; padding: 12px 14px; margin-top: 8px; border-radius: 18px; border-bottom-left-radius: 6px; background: var(--soft); animation: sd-msg-in 240ms ease; }
.sd-msg__typing i { width: 6px; height: 6px; border-radius: 99px; background: var(--mute); animation: sd-msg-dot 1s infinite; }
.sd-msg__typing i:nth-child(2) { animation-delay: 150ms; }
.sd-msg__typing i:nth-child(3) { animation-delay: 300ms; }
@keyframes sd-msg-dot { 30% { transform: translateY(-3px); opacity: 1; } 0%, 60%, 100% { opacity: 0.4; } }
.sd-msg__comp { display: flex; align-items: flex-end; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
.sd-msg__ta { flex: 1; min-height: 38px; max-height: 96px; padding: 9px 12px; border: 0; border-radius: 19px; outline: 0; resize: none; background: var(--soft); color: var(--fg); font: inherit; font-size: 14px; line-height: 1.4; field-sizing: content; }
.sd-msg__ta::placeholder { color: var(--mute); }
.sd-msg__send { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; flex: none; transition: transform 140ms, opacity 160ms; }
.sd-msg__send:disabled { opacity: 0.3; cursor: default; }
.sd-msg__send:active:not(:disabled) { transform: scale(0.88); }
.sd-msg__send svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-msg *, .sd-msg *::before, .sd-msg *::after { animation: none !important; transition: none !important; } }Overview
Sending moves the text from the composer into a new bubble that grows in from the bottom right. The status under your last bubble goes Sending → Sent → Read.
A three-dot typing bubble appears before the other side replies. Consecutive bubbles from the same person group with tighter corners.
When to use
- Support chat, DMs, comment threads on mobile.
When not to use
- Long-form discussion: use a comment list.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/message-thread.json$
npx shadcn@latest add https://sundaeui.dev/r/message-thread.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/message-thread.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/message-thread.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy message-thread.tsx and message-thread.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MessageThread } from '@/components/sundae/message-thread'
<MessageThread />Props
| Prop | Type | Default |
|---|---|---|
messages*Initial thread. | { id: string; from: "me" | "them"; text: string }[] | — |
onSendSend handler; return a reply to show it. | (text: string) => Promise<string | void> | — |
nameOther person’s name. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The thread is a log (role="log") so new messages are announced.
- The composer is a labelled textarea; Enter sends, Shift+Enter adds a line.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-msg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter
- Send
- Shift + Enter
- New line
FAQ
- Is Message Thread free?
- Yes. Message Thread is free for personal and commercial projects, with full source on this page.
- How do I install Message Thread?
- Run npx shadcn@latest add https://sundaeui.dev/r/message-thread.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Message Thread support keyboard and screen readers?
- The thread is a log (role="log") so new messages are announced. The composer is a labelled textarea; Enter sends, Shift+Enter adds a line.
- Does Message Thread respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
AI Composer
Components · Understated Reaction Burst
Components · PlayfulFollow Button
Components · RefinedVote Arrows
Components · RefinedPoll Card
Components · RefinedVoice Note
Components · Refined