Menu

Change language

GitHub repo

Chat Two Pane

blockcommunication

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.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/chat-two-pane.json
Manifest:https://uipkge.dev/r/lit/chat-two-pane.jsonInstalls to:src/uip/blocks/chat-two-pane/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

TwoPaneConversation
interface TwoPaneConversation {
  id: string
  name: string
  initials: string
  lastMessage: string
  lastAt: Date
  unread: number
  online: boolean
  pinned?: boolean
  muted?: boolean
}
TwoPaneMessage
interface TwoPaneMessage {
  id: string
  author: 'me' | 'them'
  body: string
  sentAt: Date
  status?: 'sent' | 'delivered' | 'read'
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-chat-two-pane': UipChatTwoPane
  }
}

Files installed (1)

  • src/uip/blocks/chat-two-pane/chat-two-pane.ts21.9 kB
    import { LitElement, css, html } from 'lit'
    import {
      ArrowRight,
      AtSign,
      CheckCheck,
      Filter,
      MoreVertical,
      Paperclip,
      Phone,
      Pin,
      Search,
      Smile,
      SquarePen,
      Video,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/button/button'
    import '../../components/input/input'
    import '../../components/textarea/textarea'
    import type { UipInput } from '../../components/input/input'
    
    interface TwoPaneConversation {
      id: string
      name: string
      initials: string
      lastMessage: string
      lastAt: Date
      unread: number
      online: boolean
      pinned?: boolean
      muted?: boolean
    }
    
    interface TwoPaneMessage {
      id: string
      author: 'me' | 'them'
      body: string
      sentAt: Date
      status?: 'sent' | 'delivered' | 'read'
    }
    
    function seedConversations(): TwoPaneConversation[] {
      const now = new Date()
      const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
      return [
        {
          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,
        },
      ]
    }
    
    function seedMessages(): Record<string, TwoPaneMessage[]> {
      const now = new Date()
      const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
      const yMorning = yesterday.getTime() + 9 * 60 * 60 * 1000
      return {
        c1: [
          { id: 'm1', author: 'them', body: 'Hey — did you get a chance to look at the onboarding doc?', sentAt: new Date(yMorning) },
          {
            id: 'm2',
            author: 'me',
            body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',
            sentAt: new Date(yMorning + 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(yMorning + 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 formatTime(d: Date): string {
      return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
    }
    
    function formatConvoTime(d: Date): string {
      const now = new Date()
      const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
      if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
      const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
      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' })
    }
    
    /**
     * <uip-chat-two-pane> — the registry ChatTwoPane block as a web component.
     *
     * Class strings are React's ChatTwoPane verbatim. React state becomes @state
     * fields (the per-conversation message store is a @state record of arrays;
     * sends replace the active array so Lit re-renders). Search/pin sort,
     * day-grouped bubbles, typing indicator, and Enter-to-send all behave as in
     * React. Typing-bounce keyframes are copied from React's injected <style>
     * (allowed exception); scrollbar rules are dropped (banned extra CSS).
     */
    export class UipChatTwoPane extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      // typing-bounce is copied from React's injected <style> (keyframes exception).
      static styles = [
        tailwind,
        css`:host { display: block; }`,
        css`
          .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; }
          }
        `,
      ]
    
      static properties = {
        conversations: { state: true },
        messagesByConvo: { state: true },
        search: { state: true },
        activeId: { state: true },
        draft: { state: true },
        peerTyping: { state: true },
      }
    
      private conversations: TwoPaneConversation[] = []
      private messagesByConvo: Record<string, TwoPaneMessage[]> = {}
      private search = ''
      private activeId = 'c1'
      private draft = ''
      private peerTyping = false
      private typingTimer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'chat-two-pane')
        if (this.conversations.length === 0) this.conversations = seedConversations()
        if (Object.keys(this.messagesByConvo).length === 0) this.messagesByConvo = seedMessages()
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.typingTimer) window.clearTimeout(this.typingTimer)
      }
    
      updated(changed: Map<string, unknown>) {
        if (changed.has('activeId') || changed.has('messagesByConvo')) {
          const root = this.renderRoot.querySelector('[data-scroll-root]')
          if (root) root.scrollTop = root.scrollHeight
        }
      }
    
      private get activeConvo(): TwoPaneConversation {
        return this.conversations.find((c) => c.id === this.activeId) ?? this.conversations[0]!
      }
    
      private get activeMessages(): TwoPaneMessage[] {
        return this.messagesByConvo[this.activeId] ?? []
      }
    
      private get filteredConvos(): TwoPaneConversation[] {
        const q = this.search.trim().toLowerCase()
        const list = q
          ? this.conversations.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))
          : [...this.conversations]
        return list.sort((a, b) => {
          if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
          return b.lastAt.getTime() - a.lastAt.getTime()
        })
      }
    
      private dayLabel(d: Date): string {
        const now = new Date()
        if (d.toDateString() === now.toDateString()) return 'Today'
        const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
        if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'
        return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
      }
    
      private get grouped(): Array<{ label: string; items: TwoPaneMessage[] }> {
        const groups: Array<{ label: string; items: TwoPaneMessage[] }> = []
        for (const m of this.activeMessages) {
          const label = this.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
      }
    
      private selectConvo(id: string) {
        this.activeId = id
        this.conversations = this.conversations.map((c) => (c.id === id ? { ...c, unread: 0 } : c))
      }
    
      private send() {
        const body = this.draft.trim()
        if (!body) return
        const list = [...(this.messagesByConvo[this.activeId] ?? [])]
        list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' })
        this.messagesByConvo = { ...this.messagesByConvo, [this.activeId]: list }
        this.draft = ''
        this.conversations = this.conversations.map((c) =>
          c.id === this.activeId ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c,
        )
        this.peerTyping = true
        if (this.typingTimer) window.clearTimeout(this.typingTimer)
        this.typingTimer = window.setTimeout(() => {
          this.peerTyping = false
        }, 1600)
      }
    
      render() {
        const active = this.activeConvo
        return html`
          <div
            data-slot="chat-two-pane"
            class=${cn('bg-card text-card-foreground grid h-[680px] w-full grid-cols-[280px_1fr] overflow-hidden rounded-xl border shadow-sm')}
          >
            <aside class="bg-muted/30 flex min-w-0 flex-col border-r">
              <div class="flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4">
                <h2 class="text-sm font-semibold tracking-tight">Messages</h2>
                <div class="flex items-center gap-1">
                  <uip-button variant="ghost" size="icon" class="size-8" aria-label="Filter">
                    ${icon(Filter, 'funnel', 'size-4')}
                  </uip-button>
                  <uip-button variant="ghost" size="icon" class="size-8" aria-label="New conversation">
                    ${icon(SquarePen, 'square-pen', 'size-4')}
                  </uip-button>
                </div>
              </div>
              <div class="px-3 pt-3 pb-2">
                <uip-input
                  .value=${this.search}
                  placeholder="Search"
                  class="h-8 rounded-lg text-xs"
                  @input=${(e: Event) => {
                    this.search = (e.target as UipInput).value
                  }}
                >
                  <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span>
                </uip-input>
              </div>
              <div class="convo-scroll flex-1 overflow-y-auto pb-3">
                ${this.filteredConvos.map(
                  (c) => html`
                    <button
                      class=${cn(
                        'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',
                        c.id === this.activeId ? 'bg-primary/10' : 'hover:bg-muted/60',
                      )}
                      @click=${() => this.selectConvo(c.id)}
                    >
                      <div class="relative shrink-0">
                        <uip-avatar class="size-9">
                          <uip-avatar-fallback class="text-xs">${c.initials}</uip-avatar-fallback>
                        </uip-avatar>
                        ${c.online
                          ? html`<span
                              class="bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2"
                            ></span>`
                          : ''}
                      </div>
                      <div class="min-w-0 flex-1">
                        <div class="flex items-center justify-between gap-1.5">
                          <p class=${cn('truncate text-sm', c.unread > 0 ? 'font-semibold' : 'font-medium')}>${c.name}</p>
                          <span class="text-muted-foreground shrink-0 text-xs tabular-nums"
                            >${formatConvoTime(c.lastAt)}</span
                          >
                        </div>
                        <div class="mt-0.5 flex items-center justify-between gap-1.5">
                          <p
                            class=${cn('truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground')}
                          >
                            ${c.lastMessage}
                          </p>
                          <div class="flex shrink-0 items-center gap-1">
                            ${c.pinned ? icon(Pin, 'pin', 'text-muted-foreground size-3') : ''}
                            ${c.unread > 0
                              ? html`<span
                                  class="bg-primary text-primary-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs font-bold tabular-nums"
                                  >${c.unread}</span
                                >`
                              : ''}
                          </div>
                        </div>
                      </div>
                    </button>
                  `,
                )}
              </div>
            </aside>
    
            <section class="flex min-w-0 flex-col">
              <header class="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4">
                <div class="flex min-w-0 items-center gap-3">
                  <div class="relative">
                    <uip-avatar class="size-9">
                      <uip-avatar-fallback class="text-xs font-medium">${active.initials}</uip-avatar-fallback>
                    </uip-avatar>
                    ${active.online
                      ? html`<span
                          class="bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2"
                        ></span>`
                      : ''}
                  </div>
                  <div class="min-w-0">
                    <p class="truncate text-sm font-semibold tracking-tight">${active.name}</p>
                    <p class="text-muted-foreground truncate text-xs">${active.online ? 'Active now' : 'Active 2h ago'}</p>
                  </div>
                </div>
                <div class="flex items-center gap-1">
                  <uip-button variant="ghost" size="icon" class="size-8" aria-label="Voice call">
                    ${icon(Phone, 'phone', 'size-4')}
                  </uip-button>
                  <uip-button variant="ghost" size="icon" class="size-8" aria-label="Video call">
                    ${icon(Video, 'video', 'size-4')}
                  </uip-button>
                  <uip-button variant="ghost" size="icon" class="size-8" aria-label="More options">
                    ${icon(MoreVertical, 'ellipsis-vertical', 'size-4')}
                  </uip-button>
                </div>
              </header>
    
              <div data-scroll-root class="chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4">
                ${this.grouped.map(
                  (group) => html`
                    <div class="space-y-3">
                      <div class="flex items-center justify-center">
                        <span
                          class="text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase"
                          >${group.label}</span
                        >
                      </div>
                      ${group.items.map(
                        (m) => html`
                          <div class=${cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')}>
                            ${m.author === 'them'
                              ? html`<uip-avatar class="size-7">
                                  <uip-avatar-fallback class="text-xs">${active.initials}</uip-avatar-fallback>
                                </uip-avatar>`
                              : ''}
                            <div
                              class=${cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')}
                            >
                              <div
                                class=${cn(
                                  '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 class="text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums">
                                <span>${formatTime(m.sentAt)}</span>
                                ${m.author === 'me' && m.status === 'read'
                                  ? icon(CheckCheck, 'check-check', 'text-info size-3')
                                  : ''}
                              </div>
                            </div>
                          </div>
                        `,
                      )}
                    </div>
                  `,
                )}
                ${this.peerTyping
                  ? html`
                      <div class="flex items-end gap-2">
                        <uip-avatar class="size-7">
                          <uip-avatar-fallback class="text-xs">${active.initials}</uip-avatar-fallback>
                        </uip-avatar>
                        <div
                          class="bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5"
                        >
                          <span class="typing-dot"></span>
                          <span class="typing-dot" style="animation-delay: 0.15s"></span>
                          <span class="typing-dot" style="animation-delay: 0.3s"></span>
                        </div>
                      </div>
                    `
                  : ''}
              </div>
    
              <div class="bg-muted/20 border-t px-4 py-3">
                <div
                  class="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-colors duration-150 focus-within:shadow-md focus-within:ring-4"
                >
                  <uip-textarea
                    .value=${this.draft}
                    placeholder=${`Message ${active.name.split(' ')[0]}`}
                    rows="1"
                    class="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"
                    @value-change=${(e: CustomEvent<{ value: string }>) => {
                      this.draft = e.detail.value
                    }}
                    @keydown=${(e: KeyboardEvent) => {
                      if (e.key === 'Enter' && !e.shiftKey) {
                        e.preventDefault()
                        this.send()
                      }
                    }}
                  ></uip-textarea>
                  <div class="flex items-center justify-between gap-2 px-2 pb-2">
                    <div class="flex items-center gap-0.5">
                      <uip-button
                        variant="ghost"
                        size="icon"
                        class="text-muted-foreground hover:text-foreground size-8"
                        aria-label="Attach file"
                      >
                        ${icon(Paperclip, 'paperclip', 'size-4')}
                      </uip-button>
                      <uip-button
                        variant="ghost"
                        size="icon"
                        class="text-muted-foreground hover:text-foreground size-8"
                        aria-label="Emoji"
                      >
                        ${icon(Smile, 'smile', 'size-4')}
                      </uip-button>
                      <uip-button
                        variant="ghost"
                        size="icon"
                        class="text-muted-foreground hover:text-foreground size-8"
                        aria-label="Mention"
                      >
                        ${icon(AtSign, 'at-sign', 'size-4')}
                      </uip-button>
                    </div>
                    <div class="flex items-center gap-2">
                      <kbd
                        class="bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex"
                      >
                        <span>↵</span>
                      </kbd>
                      <uip-button
                        class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium"
                        ?disabled=${!this.draft.trim()}
                        aria-label="Send"
                        @click=${this.send}
                      >
                        Send ${icon(ArrowRight, 'arrow-right', 'size-3.5')}
                      </uip-button>
                    </div>
                  </div>
                </div>
              </div>
            </section>
          </div>
        `
      }
    }
    
    customElements.get('uip-chat-two-pane') || customElements.define('uip-chat-two-pane', UipChatTwoPane)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-chat-two-pane': UipChatTwoPane
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/chat-two-pane.json