UIPackage

Chat Two Pane

block communication
Edit on GitHub

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

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