Chat Two Pane
block communicationTwo-pane messaging surface. Left rail: searchable conversation list with avatars, online dots, pinned section, unread badges, mute icons, last-message preview, smart timestamps. Right pane: active thread with day separators, own/peer bubbles, read ticks, typing indicator, attach/emoji/send composer. Stateful demo — replace `conversations` and `messagesByConvo` with your data layer.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/chat-two-pane.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/chat-two-pane.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/chat-two-pane.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/chat-two-pane.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/chat-two-pane
Examples
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Conversation interface Conversation {
id: string
name: string
initials: string
avatarUrl?: string
lastMessage: string
lastAt: Date
unread: number
online: boolean
pinned?: boolean
muted?: boolean
} Message interface Message {
id: string
author: 'me' | 'them'
body: string
sentAt: Date
status?: 'sent' | 'delivered' | 'read'
} npm dependencies
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border Files (1)
-
components/blocks/ChatTwoPane.tsx 19.8 kB
'use client' import * as React from 'react' import { ArrowRight, AtSign, Paperclip, Phone, Smile, Video, MoreVertical, Search, Pin, CheckCheck, SquarePen, Filter, } from 'lucide-react' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' interface Conversation { id: string name: string initials: string avatarUrl?: string lastMessage: string lastAt: Date unread: number online: boolean pinned?: boolean muted?: boolean } interface Message { id: string author: 'me' | 'them' body: string sentAt: Date status?: 'sent' | 'delivered' | 'read' } const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) const initialConversations: Conversation[] = [ { id: 'c1', name: 'Marcus Rivera', initials: 'MR', lastMessage: 'Yes! I will bring the kanban deck. Want to push it to 10:30?', lastAt: new Date(now.getTime() - 8 * 60 * 1000), unread: 2, online: true, pinned: true, }, { id: 'c2', name: 'Sarah Connor', initials: 'SC', lastMessage: 'Approved — feel free to take Mar 15-18 off.', lastAt: new Date(now.getTime() - 52 * 60 * 1000), unread: 0, online: true, }, { id: 'c3', name: 'Design Team', initials: 'DT', lastMessage: 'Priya: pushed the new color tokens to staging.', lastAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), unread: 5, online: false, pinned: true, }, { id: 'c4', name: 'Alice Johnson', initials: 'AJ', lastMessage: 'Thanks! Will follow up after lunch.', lastAt: new Date(now.getTime() - 5 * 60 * 60 * 1000), unread: 0, online: false, }, { id: 'c5', name: 'Engineering', initials: 'EN', lastMessage: 'Devon: deploy is green, going home.', lastAt: yesterday, unread: 0, online: false, muted: true, }, { id: 'c6', name: 'Priya Shah', initials: 'PS', lastMessage: 'Sent the spec — let me know what you think.', lastAt: new Date(now.getTime() - 28 * 60 * 60 * 1000), unread: 0, online: false, }, { id: 'c7', name: 'Devon Patel', initials: 'DP', lastMessage: 'You: pushed the fix, should be live in 10.', lastAt: new Date(now.getTime() - 50 * 60 * 60 * 1000), unread: 0, online: true, }, ] const initialMessagesByConvo: Record<string, Message[]> = { c1: [ { id: 'm1', author: 'them', body: 'Hey — did you get a chance to look at the onboarding doc?', sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000), }, { id: 'm2', author: 'me', body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.', sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 7 * 60 * 1000), status: 'read', }, { id: 'm3', author: 'them', body: 'Perfect. Drop them in the doc and I will action this afternoon.', sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 9 * 60 * 1000), }, { id: 'm4', author: 'me', body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?', sentAt: new Date(now.getTime() - 42 * 60 * 1000), status: 'read', }, { id: 'm5', author: 'them', body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?', sentAt: new Date(now.getTime() - 8 * 60 * 1000), }, ], c2: [ { id: 'm1', author: 'me', body: 'Hey Sarah — submitting time-off for Mar 15-18.', sentAt: new Date(now.getTime() - 95 * 60 * 1000), status: 'read', }, { id: 'm2', author: 'them', body: 'Approved — feel free to take Mar 15-18 off.', sentAt: new Date(now.getTime() - 52 * 60 * 1000), }, ], c3: [ { id: 'm1', author: 'them', body: 'Priya: pushed the new color tokens to staging. Reviews welcome.', sentAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), }, ], c4: [ { id: 'm1', author: 'them', body: 'Thanks! Will follow up after lunch.', sentAt: new Date(now.getTime() - 5 * 60 * 60 * 1000), }, ], c5: [{ id: 'm1', author: 'them', body: 'Devon: deploy is green, going home.', sentAt: yesterday }], c6: [ { id: 'm1', author: 'them', body: 'Sent the spec — let me know what you think.', sentAt: new Date(now.getTime() - 28 * 60 * 60 * 1000), }, ], c7: [ { id: 'm1', author: 'me', body: 'pushed the fix, should be live in 10.', sentAt: new Date(now.getTime() - 50 * 60 * 60 * 1000), status: 'read', }, ], } function dayLabel(d: Date): string { if (d.toDateString() === now.toDateString()) return 'Today' if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }) } function formatTime(d: Date): string { return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } function formatConvoTime(d: Date): string { if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)) if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } export function ChatTwoPane() { const [conversations, setConversations] = React.useState<Conversation[]>(initialConversations) // Mutable per-conversation message store. A version counter forces re-render // when a thread is mutated in place (mirrors the Vue object mutation model). const messagesByConvo = React.useRef<Record<string, Message[]>>(initialMessagesByConvo) const [, setMsgVersion] = React.useState(0) const [search, setSearch] = React.useState('') const [activeId, setActiveId] = React.useState<string>('c1') const [draft, setDraft] = React.useState('') const [peerTyping, setPeerTyping] = React.useState(false) const scrollRoot = React.useRef<HTMLDivElement | null>(null) const composerRef = React.useRef<HTMLTextAreaElement | null>(null) const sendRef = React.useRef<() => void>(() => {}) const activeConvo = React.useMemo( () => conversations.find((c) => c.id === activeId)!, [conversations, activeId], ) const activeMessages = React.useMemo<Message[]>( () => messagesByConvo.current[activeId] ?? [], // eslint-disable-next-line react-hooks/exhaustive-deps [activeId, peerTyping, draft], ) const filteredConvos = React.useMemo(() => { const q = search.trim().toLowerCase() const list = q ? conversations.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q)) : conversations return [...list].sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 return b.lastAt.getTime() - a.lastAt.getTime() }) }, [conversations, search]) const groupedMessages = React.useMemo(() => { const groups: Array<{ label: string; items: Message[] }> = [] for (const m of activeMessages) { const label = dayLabel(m.sentAt) const last = groups[groups.length - 1] if (last && last.label === label) last.items.push(m) else groups.push({ label, items: [m] }) } return groups }, [activeMessages]) function scrollToBottom() { if (!scrollRoot.current) return scrollRoot.current.scrollTop = scrollRoot.current.scrollHeight } function selectConvo(id: string) { setActiveId(id) setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, unread: 0 } : c))) } // Scroll to bottom whenever the active thread changes. React.useEffect(() => { scrollToBottom() }, [activeId]) function send() { const body = draft.trim() if (!body) return const list = messagesByConvo.current[activeId] ?? [] list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' }) messagesByConvo.current[activeId] = list setDraft('') setConversations((prev) => prev.map((c) => (c.id === activeId ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c)), ) setMsgVersion((v) => v + 1) requestAnimationFrame(scrollToBottom) setPeerTyping(true) window.setTimeout(() => setPeerTyping(false), 1600) } // The Textarea primitive's `onKeyDown` prop fires with no event, so attach a // native keydown listener to the underlying <textarea> for Enter-to-send. React.useEffect(() => { sendRef.current = send }) React.useEffect(() => { const el = composerRef.current if (!el) return function onComposerKey(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendRef.current() } } el.addEventListener('keydown', onComposerKey) return () => el.removeEventListener('keydown', onComposerKey) }, []) return ( <div className="bg-card text-card-foreground grid h-[680px] w-full grid-cols-[280px_1fr] overflow-hidden rounded-xl border shadow-sm"> <aside className="bg-muted/30 flex min-w-0 flex-col border-r"> <div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4"> <h2 className="text-sm font-semibold tracking-tight">Messages</h2> <div className="flex items-center gap-1"> <Button variant="ghost" size="icon" className="size-8" aria-label="Filter"> <Filter className="size-4" /> </Button> <Button variant="ghost" size="icon" className="size-8" aria-label="New conversation"> <SquarePen className="size-4" /> </Button> </div> </div> <div className="px-3 pt-3 pb-2"> <div className="relative"> <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search" className="h-8 rounded-lg pl-8 text-xs" /> </div> </div> <div className="convo-scroll flex-1 overflow-y-auto pb-3"> {filteredConvos.map((c) => ( <button key={c.id} className={`flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors ${ c.id === activeId ? 'bg-primary/10' : 'hover:bg-muted/60' }`} onClick={() => selectConvo(c.id)} > <div className="relative shrink-0"> <Avatar className="size-9"> {c.avatarUrl && <AvatarImage src={c.avatarUrl} alt={c.name} />} <AvatarFallback className="text-[11px]">{c.initials}</AvatarFallback> </Avatar> {c.online && ( <span className="bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2" /> )} </div> <div className="min-w-0 flex-1"> <div className="flex items-center justify-between gap-1.5"> <p className={`truncate text-[13px] ${c.unread > 0 ? 'font-semibold' : 'font-medium'}`}> {c.name} </p> <span className="text-muted-foreground shrink-0 text-[10px] tabular-nums"> {formatConvoTime(c.lastAt)} </span> </div> <div className="mt-0.5 flex items-center justify-between gap-1.5"> <p className={`truncate text-xs ${c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground'}`}> {c.lastMessage} </p> <div className="flex shrink-0 items-center gap-1"> {c.pinned && <Pin className="text-muted-foreground size-3" />} {c.unread > 0 && ( <span className="bg-primary text-primary-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold tabular-nums"> {c.unread} </span> )} </div> </div> </div> </button> ))} </div> </aside> <section className="flex min-w-0 flex-col"> <header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4"> <div className="flex min-w-0 items-center gap-3"> <div className="relative"> <Avatar className="size-9"> <AvatarFallback className="text-xs font-medium">{activeConvo.initials}</AvatarFallback> </Avatar> {activeConvo.online && ( <span className="bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2" /> )} </div> <div className="min-w-0"> <p className="truncate text-sm font-semibold tracking-tight">{activeConvo.name}</p> <p className="text-muted-foreground truncate text-xs"> {activeConvo.online ? 'Active now' : 'Active 2h ago'} </p> </div> </div> <div className="flex items-center gap-1"> <Button variant="ghost" size="icon" className="size-8" aria-label="Voice call"> <Phone className="size-4" /> </Button> <Button variant="ghost" size="icon" className="size-8" aria-label="Video call"> <Video className="size-4" /> </Button> <Button variant="ghost" size="icon" className="size-8" aria-label="More options"> <MoreVertical className="size-4" /> </Button> </div> </header> <div ref={scrollRoot} className="chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4"> {groupedMessages.map((group) => ( <div key={group.label} className="space-y-3"> <div className="flex items-center justify-center"> <span className="text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"> {group.label} </span> </div> {group.items.map((m) => ( <div key={m.id} className={`flex items-end gap-2 ${m.author === 'me' ? 'justify-end' : 'justify-start'}`} > {m.author === 'them' && ( <Avatar className="size-7"> <AvatarFallback className="text-[10px]">{activeConvo.initials}</AvatarFallback> </Avatar> )} <div className={`flex max-w-[78%] flex-col gap-1 ${m.author === 'me' ? 'items-end' : 'items-start'}`} > <div className={`rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm ${ m.author === 'me' ? 'bg-primary text-primary-foreground rounded-br-md' : 'bg-muted text-foreground rounded-bl-md' }`} > {m.body} </div> <div className="text-muted-foreground flex items-center gap-1 px-1 text-[10px] tabular-nums"> <span>{formatTime(m.sentAt)}</span> {m.author === 'me' && m.status === 'read' && <CheckCheck className="text-info size-3" />} </div> </div> </div> ))} </div> ))} {peerTyping && ( <div className="flex items-end gap-2"> <Avatar className="size-7"> <AvatarFallback className="text-[10px]">{activeConvo.initials}</AvatarFallback> </Avatar> <div className="bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5"> <span className="typing-dot" /> <span className="typing-dot" style={{ animationDelay: '0.15s' }} /> <span className="typing-dot" style={{ animationDelay: '0.3s' }} /> </div> </div> )} </div> <div className="bg-muted/20 border-t px-4 py-3"> <div className="bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-all duration-150 focus-within:shadow-md focus-within:ring-4"> <Textarea ref={composerRef} value={draft} onValueChange={setDraft} placeholder={`Message ${activeConvo.name.split(' ')[0]}`} rows={1} inputClassName="placeholder:text-muted-foreground/70 max-h-40 min-h-[44px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0" /> <div className="flex items-center justify-between gap-2 px-2 pb-2"> <div className="flex items-center gap-0.5"> <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground size-8" aria-label="Attach file" > <Paperclip className="size-4" /> </Button> <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground size-8" aria-label="Emoji" > <Smile className="size-4" /> </Button> <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground size-8" aria-label="Mention" > <AtSign className="size-4" /> </Button> </div> <div className="flex items-center gap-2"> <kbd className="bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-[10px] sm:inline-flex"> <span>↵</span> </kbd> <Button className="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" disabled={!draft.trim()} aria-label="Send" onClick={send} > Send <ArrowRight className="size-3.5" /> </Button> </div> </div> </div> </div> </section> <style>{` .chat-scroll::-webkit-scrollbar, .convo-scroll::-webkit-scrollbar { width: 6px; } .chat-scroll::-webkit-scrollbar-thumb, .convo-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } .typing-dot { display: inline-block; width: 6px; height: 6px; border-radius: 9999px; background: currentColor; opacity: 0.5; animation: typing-bounce 1s infinite ease-in-out; } @keyframes typing-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.35; } 40% { transform: translateY(-3px); opacity: 0.9; } } `}</style> </div> ) }
Raw manifest: https://react.uipkge.dev/r/react/chat-two-pane.json