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 uipkge-ng@latest add chat-two-pane$npx uipkge-ng@latest add chat-two-pane$yarn dlx uipkge-ng@latest add chat-two-pane$bunx uipkge-ng@latest add chat-two-paneManifest:
https://uipkge.dev/r/angular/chat-two-pane.jsonInstalls to:src/app/components/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.
ChatTwoPaneConversationinterface ChatTwoPaneConversation {
id: string
name: string
initials: string
avatarUrl?: string
lastMessage: string
lastAt: Date
unread: number
online: boolean
pinned?: boolean
muted?: boolean
}ChatTwoPaneMessageinterface ChatTwoPaneMessage {
id: string
author: 'me' | 'them'
body: string
sentAt: Date
status?: 'sent' | 'delivered' | 'read'
}ChatTwoPaneMessageGroupinterface ChatTwoPaneMessageGroup {
label: string
items: ChatTwoPaneMessage[]
}npm dependencies
Files installed (2)
src/app/components/blocks/chat-two-pane/chat-two-pane.component.ts22.3 kB
import { Component, DestroyRef, ElementRef, ViewChild, afterRenderEffect, computed, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { ArrowRight, AtSign, CheckCheck, Filter, LucideAngularModule, MoreVertical, Paperclip, Phone, Pin, Search, Smile, SquarePen, Video, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface ChatTwoPaneConversation { id: string name: string initials: string avatarUrl?: string lastMessage: string lastAt: Date unread: number online: boolean pinned?: boolean muted?: boolean } export interface ChatTwoPaneMessage { id: string author: 'me' | 'them' body: string sentAt: Date status?: 'sent' | 'delivered' | 'read' } export interface ChatTwoPaneMessageGroup { label: string items: ChatTwoPaneMessage[] } 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: ChatTwoPaneConversation[] = [ { 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, ChatTwoPaneMessage[]> = { 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' }) } // The typing dots (React injects the same rules as a <style> tag): a keyframe animation, // which a utility cannot declare. Longhand animation properties so the per-dot // `[animation-delay:…]` utilities (React's inline animationDelay) are not reset by an // unlayered `animation` shorthand. The thin scrollbars are `[&::-webkit-scrollbar]` utilities. const TYPING_STYLES = ` .typing-dot { display: inline-block; width: 6px; height: 6px; border-radius: 9999px; background: currentColor; opacity: 0.5; animation-name: typing-bounce; animation-duration: 1s; animation-iteration-count: infinite; animation-timing-function: ease-in-out; } @keyframes typing-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.35; } 40% { transform: translateY(-3px); opacity: 0.9; } } ` /** * Two-pane messaging surface (Angular port of the React `ChatTwoPane` block). Left rail: * searchable conversation list; right pane: the active thread and an Enter-to-send composer. * Stateful demo -- replace `conversations` and `messagesByConvo` with your data layer. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-chat-two-pane, [ui-chat-two-pane]', standalone: true, host: { class: 'contents' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent, UiButtonComponent, UiInputComponent, UiTextareaComponent, ], styles: [TYPING_STYLES], template: ` <div data-slot="chat-two-pane" class="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"> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Filter"> <lucide-icon [img]="Filter" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="New conversation"> <lucide-icon [img]="SquarePen" class="size-4" /> </button> </div> </div> <div class="px-3 pt-3 pb-2"> <div class="relative"> <!-- [class] (not class=) styles only the svg: lucide-angular copies a static class onto both its host and the svg, which would apply the absolute offsets twice. --> <lucide-icon [img]="Search" [class]="'text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'" /> <ui-input [value]="search()" (valueChange)="search.set($event)" placeholder="Search" class="h-8 rounded-lg pl-8 text-xs" /> </div> </div> <div class="[&::-webkit-scrollbar-thumb]:bg-border flex-1 overflow-y-auto pb-3 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-[3px]" > @for (c of filteredConvos(); track c.id) { <button type="button" [class]="convoRowClass(c)" (click)="selectConvo(c.id)"> <div class="relative shrink-0"> <ui-avatar class="size-9"> @if (c.avatarUrl) { <ui-avatar-image [src]="c.avatarUrl" [alt]="c.name" /> } <ui-avatar-fallback class="text-xs">{{ c.initials }}</ui-avatar-fallback> </ui-avatar> @if (c.online) { <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]="convoNameClass(c)">{{ 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]="convoPreviewClass(c)">{{ c.lastMessage }}</p> <div class="flex shrink-0 items-center gap-1"> @if (c.pinned) { <lucide-icon [img]="Pin" class="text-muted-foreground size-3" /> } @if (c.unread > 0) { <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"> <ui-avatar class="size-9"> <ui-avatar-fallback class="text-xs font-medium">{{ activeConvo().initials }}</ui-avatar-fallback> </ui-avatar> @if (activeConvo().online) { <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">{{ activeConvo().name }}</p> <p class="text-muted-foreground truncate text-xs"> {{ activeConvo().online ? 'Active now' : 'Active 2h ago' }} </p> </div> </div> <div class="flex items-center gap-1"> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Voice call"> <lucide-icon [img]="Phone" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="Video call"> <lucide-icon [img]="Video" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="size-8" aria-label="More options"> <lucide-icon [img]="MoreVertical" class="size-4" /> </button> </div> </header> <div #scrollRoot class="[&::-webkit-scrollbar-thumb]:bg-border flex-1 space-y-5 overflow-y-auto px-4 py-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-[3px]" > @for (group of groupedMessages(); track group.label) { <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> @for (m of group.items; track m.id) { <div [class]="messageRowClass(m)"> @if (m.author === 'them') { <ui-avatar class="size-7"> <ui-avatar-fallback class="text-xs">{{ activeConvo().initials }}</ui-avatar-fallback> </ui-avatar> } <div [class]="messageColumnClass(m)"> <div [class]="bubbleClass(m)">{{ m.body }}</div> <div class="text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums"> <span>{{ formatTime(m.sentAt) }}</span> @if (m.author === 'me' && m.status === 'read') { <lucide-icon [img]="CheckCheck" class="text-info size-3" /> } </div> </div> </div> } </div> } @if (peerTyping()) { <div class="flex items-end gap-2"> <ui-avatar class="size-7"> <ui-avatar-fallback class="text-xs">{{ activeConvo().initials }}</ui-avatar-fallback> </ui-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 [animation-delay:0.15s]"></span> <span class="typing-dot [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" > <ui-textarea [value]="draft()" (valueChange)="draft.set($event)" [placeholder]="composerPlaceholder()" 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" (keyDown)="onComposerKey($event)" /> <div class="flex items-center justify-between gap-2 px-2 pb-2"> <div class="flex items-center gap-0.5"> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" aria-label="Attach file" > <lucide-icon [img]="Paperclip" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" aria-label="Emoji" > <lucide-icon [img]="Smile" class="size-4" /> </button> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" aria-label="Mention" > <lucide-icon [img]="AtSign" class="size-4" /> </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> <button ui-button class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" [disabled]="!draft().trim()" aria-label="Send" (click)="send()" > Send <lucide-icon [img]="ArrowRight" class="size-3.5" /> </button> </div> </div> </div> </div> </section> </div> `, }) export class UiChatTwoPaneComponent { protected readonly ArrowRight = ArrowRight protected readonly AtSign = AtSign protected readonly CheckCheck = CheckCheck protected readonly Filter = Filter protected readonly MoreVertical = MoreVertical protected readonly Paperclip = Paperclip protected readonly Phone = Phone protected readonly Pin = Pin protected readonly Search = Search protected readonly Smile = Smile protected readonly SquarePen = SquarePen protected readonly Video = Video @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement> readonly conversations = signal<ChatTwoPaneConversation[]>(initialConversations.map((c) => ({ ...c }))) /** Per-conversation message store (React keeps it in a ref + version counter). */ readonly messagesByConvo = signal<Record<string, ChatTwoPaneMessage[]>>( Object.fromEntries(Object.entries(initialMessagesByConvo).map(([id, list]) => [id, [...list]])), ) readonly search = signal('') readonly activeId = signal('c1') readonly draft = signal('') readonly peerTyping = signal(false) private typingTimer: ReturnType<typeof setTimeout> | null = null readonly activeConvo = computed(() => this.conversations().find((c) => c.id === this.activeId())!) readonly activeMessages = computed<ChatTwoPaneMessage[]>(() => this.messagesByConvo()[this.activeId()] ?? []) readonly composerPlaceholder = computed(() => `Message ${this.activeConvo().name.split(' ')[0]}`) readonly filteredConvos = computed(() => { 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() }) }) readonly groupedMessages = computed(() => { const groups: ChatTwoPaneMessageGroup[] = [] for (const m of this.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 }) constructor() { // React: scroll to the newest message when the active thread changes and after a send. afterRenderEffect(() => { this.activeId() this.activeMessages().length this.scrollToBottom() }) inject(DestroyRef).onDestroy(() => { if (this.typingTimer) clearTimeout(this.typingTimer) }) } formatTime(d: Date): string { return formatTime(d) } formatConvoTime(d: Date): string { return formatConvoTime(d) } convoRowClass(c: ChatTwoPaneConversation): string { return 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', ) } convoNameClass(c: ChatTwoPaneConversation): string { return cn('truncate text-sm', c.unread > 0 ? 'font-semibold' : 'font-medium') } convoPreviewClass(c: ChatTwoPaneConversation): string { return cn('truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground') } messageRowClass(m: ChatTwoPaneMessage): string { return cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start') } messageColumnClass(m: ChatTwoPaneMessage): string { return cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start') } bubbleClass(m: ChatTwoPaneMessage): string { return 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', ) } selectConvo(id: string): void { this.activeId.set(id) this.conversations.update((list) => list.map((c) => (c.id === id ? { ...c, unread: 0 } : c))) } send(): void { const body = this.draft().trim() if (!body) return const id = this.activeId() this.messagesByConvo.update((store) => ({ ...store, [id]: [...(store[id] ?? []), { id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' }], })) this.draft.set('') this.conversations.update((list) => list.map((c) => (c.id === id ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c)), ) this.peerTyping.set(true) if (this.typingTimer) clearTimeout(this.typingTimer) this.typingTimer = setTimeout(() => this.peerTyping.set(false), 1600) } onComposerKey(e: KeyboardEvent): void { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() this.send() } } private scrollToBottom(): void { const el = this.scrollRoot?.nativeElement if (!el) return el.scrollTop = el.scrollHeight } }src/app/components/blocks/chat-two-pane/index.ts0.2 kB
export { UiChatTwoPaneComponent, type ChatTwoPaneConversation, type ChatTwoPaneMessage, type ChatTwoPaneMessageGroup, } from './chat-two-pane.component'
Raw manifest:https://uipkge.dev/r/angular/chat-two-pane.json