
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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 ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/chat-two-pane.json$npx shadcn@latest add https://uipkge.dev/r/react/chat-two-pane.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/chat-two-pane.json$bunx shadcn@latest add https://uipkge.dev/r/react/chat-two-pane.jsonnpx shadcn@latest add @uipkge-react/chat-two-paneInstalls to:components/blocks/Type aliases exported from this item's source. Use these to shape the data you pass in.
Conversationinterface Conversation {
id: string
name: string
initials: string
avatarUrl?: string
lastMessage: string
lastAt: Date
unread: number
online: boolean
pinned?: boolean
muted?: boolean
}Messageinterface Message {
id: string
author: 'me' | 'them'
body: string
sentAt: Date
status?: 'sent' | 'delivered' | 'read'
}CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border'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
data-slot="chat-two-pane"
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-xs">{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-sm ${c.unread > 0 ? 'font-semibold' : 'font-medium'}`}>{c.name}</p>
<span className="text-muted-foreground shrink-0 text-xs 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-xs 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-xs 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-xs">{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-xs 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-xs">{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-colors 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-xs 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://uipkge.dev/r/react/chat-two-pane.json