Chat Two Pane
blockcommunicationTwo-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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/chat-two-pane.json$npx shadcn@latest add https://uipkge.dev/r/lit/chat-two-pane.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/chat-two-pane.json$bunx shadcn@latest add https://uipkge.dev/r/lit/chat-two-pane.jsonManifest:
https://uipkge.dev/r/lit/chat-two-pane.jsonInstalls to:src/uip/blocks/chat-two-pane/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
TwoPaneConversationinterface TwoPaneConversation {
id: string
name: string
initials: string
lastMessage: string
lastAt: Date
unread: number
online: boolean
pinned?: boolean
muted?: boolean
}TwoPaneMessageinterface TwoPaneMessage {
id: string
author: 'me' | 'them'
body: string
sentAt: Date
status?: 'sent' | 'delivered' | 'read'
}HTMLElementTagNameMapinterface 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