Chat Header
Original · freechat window header with actions
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chat-header.json"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Presence = "online" | "busy" | "away" | "offline";
type CallKind = "voice" | "video";
type Member = {
id: string;
name: string;
role: string;
initials: string;
presence: Presence;
ring: string;
};
type Message = {
id: string;
authorId: string;
text: string;
time: string;
};
const MEMBERS: Member[] = [
{
id: "priya",
name: "Priya Raman",
role: "Staff engineer",
initials: "PR",
presence: "online",
ring: "from-indigo-500 to-violet-600",
},
{
id: "dane",
name: "Dane Okonkwo",
role: "Product design",
initials: "DO",
presence: "busy",
ring: "from-emerald-500 to-sky-600",
},
{
id: "lena",
name: "Lena Fischer",
role: "Payments lead",
initials: "LF",
presence: "away",
ring: "from-amber-500 to-rose-500",
},
{
id: "tom",
name: "Tomás Ferreira",
role: "QA",
initials: "TF",
presence: "offline",
ring: "from-slate-500 to-zinc-600",
},
];
const MESSAGES: Message[] = [
{
id: "m1",
authorId: "priya",
text: "Staging is live with the one-page checkout. Card, Apple Pay and iDEAL all clear the sandbox now.",
time: "09:41",
},
{
id: "m2",
authorId: "dane",
text: "Ran it on a 5-year-old Android. The step indicator no longer wraps to two lines — that was the last blocker on my list.",
time: "09:44",
},
{
id: "m3",
authorId: "lena",
text: "Refund path still returns a 402 when the original charge was split across two payment methods. I logged it as PAY-318.",
time: "09:52",
},
{
id: "m4",
authorId: "priya",
text: "Looking at PAY-318 now. It's the reconciliation job reading only the first charge id — small fix, big blast radius.",
time: "09:55",
},
{
id: "m5",
authorId: "tom",
text: "Regression suite: 214 passing, 2 skipped. The skips are the split-refund cases, so they'll go green with Priya's patch.",
time: "10:03",
},
{
id: "m6",
authorId: "dane",
text: "Then I think we ship behind the flag on Thursday and ramp to 10% of EU traffic Friday morning.",
time: "10:07",
},
];
const PRESENCE_DOT: Record<Presence, string> = {
online: "bg-emerald-500",
busy: "bg-rose-500",
away: "bg-amber-500",
offline: "bg-slate-400 dark:bg-slate-600",
};
const PRESENCE_LABEL: Record<Presence, string> = {
online: "Online",
busy: "In a meeting",
away: "Away",
offline: "Offline",
};
function byId(id: string): Member {
return MEMBERS.find((m) => m.id === id) ?? MEMBERS[0];
}
function formatClock(total: number): string {
const mm = Math.floor(total / 60)
.toString()
.padStart(2, "0");
const ss = (total % 60).toString().padStart(2, "0");
return `${mm}:${ss}`;
}
function highlight(text: string, query: string): ReactNode {
const needle = query.trim().toLowerCase();
if (!needle) return text;
const hay = text.toLowerCase();
const out: ReactNode[] = [];
let cursor = 0;
let key = 0;
for (;;) {
const at = hay.indexOf(needle, cursor);
if (at === -1) {
out.push(text.slice(cursor));
break;
}
if (at > cursor) out.push(text.slice(cursor, at));
out.push(
<mark
key={`hl-${key++}`}
className="rounded-[3px] bg-amber-200 px-0.5 text-slate-900 dark:bg-amber-300/80 dark:text-slate-900"
>
{text.slice(at, at + needle.length)}
</mark>,
);
cursor = at + needle.length;
}
return out;
}
const ICON = "h-[18px] w-[18px]";
function IconSearch() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
<path d="m16 16 4.5 4.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function IconPhone() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<path
d="M6.2 3.8h3l1.4 3.6-2 1.5a11.4 11.4 0 0 0 4.9 4.9l1.5-2 3.6 1.4v3a1.8 1.8 0 0 1-2 1.8C10.3 17.5 6 13.2 4.4 5.8a1.8 1.8 0 0 1 1.8-2Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconVideo() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<rect x="3" y="6.5" width="11.5" height="11" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
<path
d="m15 11 4.4-2.7c.6-.4 1.4 0 1.4.8v5.8c0 .8-.8 1.2-1.4.8L15 13z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconInfo() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<circle cx="12" cy="12" r="8.6" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 11v5.2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<circle cx="12" cy="7.9" r="1.05" fill="currentColor" />
</svg>
);
}
function IconDots() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={ICON}>
<circle cx="12" cy="5.5" r="1.7" />
<circle cx="12" cy="12" r="1.7" />
<circle cx="12" cy="18.5" r="1.7" />
</svg>
);
}
function IconClose() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<path d="m6.5 6.5 11 11m0-11-11 11" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function IconBellOff() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<path
d="M8.2 6.1A4.8 4.8 0 0 1 16.8 9v3.3l1.6 3.2H7.4m-1.2-2.6.8-1.6V9c0-.5.1-1 .2-1.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10.2 18.6a2 2 0 0 0 3.6 0M4 4l16 16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function IconPin() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<path
d="M14.4 3.6 20.4 9.6l-2.3.5a3 3 0 0 0-1.6.9l-2.3 2.4.7 3.2-6-6 3.2.7 2.4-2.3a3 3 0 0 0 .9-1.6z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M8.9 15.1 4.5 19.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function IconCheck() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="m5 12.5 4.2 4.2L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconHangUp() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
<path
d="M3.6 13.4c4.6-4.4 12.2-4.4 16.8 0 .8.8.7 1.7 0 2.4l-1.6 1.5c-.6.6-1.4.5-2-.1l-1.3-1.5c-.3-.4-.4-.6-.4-1.2v-1c-2.3-.7-4.5-.7-6.8 0v1c0 .6-.1.8-.4 1.2l-1.3 1.5c-.6.6-1.4.7-2 .1l-1.6-1.5c-.7-.7-.8-1.6.6-2.4Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
const iconBtn =
"inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-950";
const iconBtnOn =
"inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25 dark:focus-visible:ring-offset-slate-950";
type MenuEntry = {
id: string;
label: string;
checkbox: boolean;
checked?: boolean;
danger?: boolean;
run: () => void;
};
export default function ChatHeader() {
const reduce = useReducedMotion();
const uid = useId();
const menuId = `${uid}-menu`;
const searchId = `${uid}-search`;
const detailsId = `${uid}-details`;
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [detailsOpen, setDetailsOpen] = useState(false);
const [muted, setMuted] = useState(false);
const [pinned, setPinned] = useState(true);
const [unread, setUnread] = useState(false);
const [call, setCall] = useState<CallKind | null>(null);
const [micOff, setMicOff] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [announce, setAnnounce] = useState("");
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const searchBtnRef = useRef<HTMLButtonElement | null>(null);
const detailsBtnRef = useRef<HTMLButtonElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const menuWrapRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const online = MEMBERS.filter((m) => m.presence === "online" || m.presence === "busy").length;
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return MESSAGES;
return MESSAGES.filter(
(m) => m.text.toLowerCase().includes(q) || byId(m.authorId).name.toLowerCase().includes(q),
);
}, [query]);
useEffect(() => {
if (call === null) return;
const timer = window.setInterval(() => setElapsed((s) => s + 1), 1000);
return () => window.clearInterval(timer);
}, [call]);
useEffect(() => {
if (!menuOpen) return;
itemRefs.current[activeIndex]?.focus();
}, [menuOpen, activeIndex]);
useEffect(() => {
if (!menuOpen) return;
const onPointer = (event: PointerEvent) => {
if (!menuWrapRef.current) return;
if (event.target instanceof Node && !menuWrapRef.current.contains(event.target)) {
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", onPointer);
return () => document.removeEventListener("pointerdown", onPointer);
}, [menuOpen]);
const startCall = (kind: CallKind) => {
setElapsed(0);
setMicOff(false);
setCall(kind);
setMenuOpen(false);
setAnnounce(`${kind === "video" ? "Video" : "Voice"} call started with Checkout rebuild`);
};
const endCall = () => {
setAnnounce(`Call ended after ${formatClock(elapsed)}`);
setCall(null);
};
const openSearch = () => {
setSearchOpen(true);
setMenuOpen(false);
window.setTimeout(() => searchInputRef.current?.focus(), 20);
};
const closeSearch = () => {
setSearchOpen(false);
setQuery("");
searchBtnRef.current?.focus();
};
const toggleDetails = () => {
const next = !detailsOpen;
setDetailsOpen(next);
setAnnounce(next ? "Chat details opened" : "Chat details closed");
};
const entries: MenuEntry[] = [
{
id: "video",
label: "Start video call",
checkbox: false,
run: () => startCall("video"),
},
{
id: "details",
label: "Chat details",
checkbox: false,
run: () => {
setMenuOpen(false);
setDetailsOpen(true);
setAnnounce("Chat details opened");
},
},
{
id: "mute",
label: "Mute notifications",
checkbox: true,
checked: muted,
run: () => {
setMuted(!muted);
setAnnounce(muted ? "Notifications unmuted" : "Notifications muted");
},
},
{
id: "pin",
label: "Pin to top of inbox",
checkbox: true,
checked: pinned,
run: () => {
setPinned(!pinned);
setAnnounce(pinned ? "Conversation unpinned" : "Conversation pinned");
},
},
{
id: "unread",
label: "Mark as unread",
checkbox: false,
run: () => {
setUnread(true);
setMenuOpen(false);
setAnnounce("Conversation marked as unread");
},
},
{
id: "export",
label: "Export transcript (.txt)",
checkbox: false,
run: () => {
setMenuOpen(false);
setAnnounce(`Transcript queued for export, ${MESSAGES.length} messages`);
},
},
];
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const last = entries.length - 1;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => (i >= last ? 0 : i + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => (i <= 0 ? last : i - 1));
} else if (event.key === "Home") {
event.preventDefault();
setActiveIndex(0);
} else if (event.key === "End") {
event.preventDefault();
setActiveIndex(last);
} else if (event.key === "Escape") {
event.preventDefault();
setMenuOpen(false);
menuBtnRef.current?.focus();
} else if (event.key === "Tab") {
setMenuOpen(false);
}
};
const onMenuButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveIndex(0);
setMenuOpen(true);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex(entries.length - 1);
setMenuOpen(true);
}
};
const subtitle = call
? `${call === "video" ? "Video" : "Voice"} call in progress`
: muted
? "Notifications muted"
: `${MEMBERS.length} members · ${online} active now`;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes chdr-halo { 0% { transform: scale(1); opacity: .5 } 70%, 100% { transform: scale(2.4); opacity: 0 } }
@keyframes chdr-blink { 0%, 100% { opacity: 1 } 50% { opacity: .3 } }
@media (prefers-reduced-motion: reduce) {
.chdr-halo, .chdr-blink { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
{/* ── header ─────────────────────────────────────── */}
<header className="relative border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
<div className="flex items-center gap-3 px-3 py-3 sm:px-4">
{/* avatar stack */}
<div className="relative flex shrink-0 items-center">
<div className="flex -space-x-2">
{MEMBERS.slice(0, 3).map((m) => (
<span
key={m.id}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br ${m.ring} text-[11px] font-semibold tracking-wide text-white ring-2 ring-white dark:ring-slate-900`}
>
{m.initials}
</span>
))}
</div>
<span className="relative -ml-1 flex h-3 w-3 self-end">
{!reduce && (
<span className="chdr-halo absolute inset-0 rounded-full bg-emerald-500 [animation:chdr-halo_2.4s_cubic-bezier(0,0,.2,1)_infinite]" />
)}
<span className="relative h-3 w-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-slate-900" />
</span>
</div>
{/* title block */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<h2 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
Checkout rebuild
</h2>
{pinned && (
<span
className="inline-flex shrink-0 text-indigo-500 dark:text-indigo-400"
title="Pinned conversation"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M14.4 3.6 20.4 9.6l-2.3.5a3 3 0 0 0-1.6.9l-2.3 2.4.7 3.2-6-6 3.2.7 2.4-2.3a3 3 0 0 0 .9-1.6zM8.9 15.1 4.5 19.5" />
</svg>
<span className="sr-only">Pinned</span>
</span>
)}
{unread && (
<span className="inline-flex shrink-0 items-center rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300">
Unread
</span>
)}
</div>
<p className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-slate-500 dark:text-slate-400">
{call && (
<span
className={`chdr-blink h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500 ${
reduce ? "" : "[animation:chdr-blink_1.4s_ease-in-out_infinite]"
}`}
/>
)}
{muted && !call && <span className="shrink-0 text-slate-400 dark:text-slate-500">{"⊘"}</span>}
<span className="truncate">{subtitle}</span>
</p>
</div>
{/* actions */}
<div className="flex shrink-0 items-center gap-0.5">
<button
ref={searchBtnRef}
type="button"
className={searchOpen ? iconBtnOn : iconBtn}
aria-expanded={searchOpen}
aria-controls={searchId}
onClick={() => (searchOpen ? closeSearch() : openSearch())}
>
<IconSearch />
<span className="sr-only">Search this conversation</span>
</button>
<button
type="button"
className={call === "voice" ? iconBtnOn : iconBtn}
onClick={() => (call === "voice" ? endCall() : startCall("voice"))}
>
<IconPhone />
<span className="sr-only">{call === "voice" ? "End voice call" : "Start voice call"}</span>
</button>
<button
type="button"
className={`hidden sm:inline-flex ${call === "video" ? iconBtnOn : iconBtn}`}
onClick={() => (call === "video" ? endCall() : startCall("video"))}
>
<IconVideo />
<span className="sr-only">{call === "video" ? "End video call" : "Start video call"}</span>
</button>
<button
ref={detailsBtnRef}
type="button"
className={`hidden sm:inline-flex ${detailsOpen ? iconBtnOn : iconBtn}`}
aria-expanded={detailsOpen}
aria-controls={detailsId}
onClick={toggleDetails}
>
<IconInfo />
<span className="sr-only">Chat details</span>
</button>
<div className="relative" ref={menuWrapRef}>
<button
ref={menuBtnRef}
type="button"
className={menuOpen ? iconBtnOn : iconBtn}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuId}
onKeyDown={onMenuButtonKeyDown}
onClick={() => {
setActiveIndex(0);
setMenuOpen((v) => !v);
}}
>
<IconDots />
<span className="sr-only">More actions</span>
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
id={menuId}
role="menu"
aria-label="Conversation actions"
onKeyDown={onMenuKeyDown}
initial={reduce ? false : { opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 top-11 z-30 w-60 origin-top-right overflow-hidden rounded-xl border border-slate-200 bg-white p-1 shadow-lg shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/50"
>
{entries.map((entry, i) => (
<button
key={entry.id}
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role={entry.checkbox ? "menuitemcheckbox" : "menuitem"}
aria-checked={entry.checkbox ? entry.checked : undefined}
tabIndex={i === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(i)}
onClick={entry.run}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-700 transition-colors hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-slate-200 dark:hover:bg-slate-700/70 dark:focus-visible:bg-slate-700/70"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-indigo-600 dark:text-indigo-400">
{entry.checkbox && entry.checked ? <IconCheck /> : null}
{entry.id === "video" && !entry.checkbox ? <IconVideo /> : null}
{entry.id === "details" && !entry.checkbox ? <IconInfo /> : null}
</span>
<span className="flex-1">{entry.label}</span>
{entry.id === "mute" && (
<span className="text-slate-400 dark:text-slate-500">
<IconBellOff />
</span>
)}
{entry.id === "pin" && (
<span className="text-slate-400 dark:text-slate-500">
<IconPin />
</span>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* search row */}
<AnimatePresence initial={false}>
{searchOpen && (
<motion.div
id={searchId}
key="search"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden border-t border-slate-200 dark:border-slate-800"
>
<div className="flex items-center gap-2 px-3 py-2.5 sm:px-4">
<span className="text-slate-400 dark:text-slate-500">
<IconSearch />
</span>
<input
ref={searchInputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
closeSearch();
}
}}
placeholder="Search messages, e.g. refund"
aria-label="Search messages in Checkout rebuild"
className="w-full bg-transparent text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<span
aria-live="polite"
className="shrink-0 text-xs tabular-nums text-slate-500 dark:text-slate-400"
>
{query.trim() ? `${results.length} of ${MESSAGES.length}` : ""}
</span>
<button type="button" onClick={closeSearch} className={iconBtn}>
<IconClose />
<span className="sr-only">Close search</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* call ribbon */}
<AnimatePresence initial={false}>
{call && (
<motion.div
key="call"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden border-t border-emerald-200 bg-emerald-50 dark:border-emerald-500/25 dark:bg-emerald-500/10"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5 sm:px-4">
<span className="text-emerald-700 dark:text-emerald-300">
{call === "video" ? <IconVideo /> : <IconPhone />}
</span>
<p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
{call === "video" ? "Video call" : "Voice call"} · 3 joined
</p>
<span className="rounded-md bg-emerald-600/10 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-200">
{formatClock(elapsed)}
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
aria-pressed={micOff}
onClick={() => {
setMicOff(!micOff);
setAnnounce(micOff ? "Microphone on" : "Microphone muted");
}}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1 text-xs font-medium text-emerald-800 transition-colors hover:bg-emerald-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:border-emerald-500/30 dark:bg-slate-900 dark:text-emerald-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
{micOff ? "Unmute mic" : "Mute mic"}
</button>
<button
type="button"
onClick={endCall}
className="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:focus-visible:ring-offset-slate-900"
>
<IconHangUp />
End
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
{/* ── body ───────────────────────────────────────── */}
<div className="relative">
<div className="h-[22rem] overflow-y-auto bg-slate-50/60 px-3 py-4 sm:px-4 dark:bg-slate-950/40">
{results.length === 0 ? (
<p className="py-12 text-center text-sm text-slate-500 dark:text-slate-400">
No messages match “{query.trim()}”.
</p>
) : (
<ul className="space-y-3">
{results.map((message) => {
const author = byId(message.authorId);
return (
<li key={message.id} className="flex gap-2.5">
<span
className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${author.ring} text-[10px] font-semibold text-white`}
>
{author.initials}
</span>
<div className="min-w-0">
<p className="flex items-baseline gap-2">
<span className="text-xs font-semibold text-slate-900 dark:text-slate-100">
{author.name}
</span>
<span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{message.time}
</span>
</p>
<p className="mt-0.5 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{highlight(message.text, query)}
</p>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* details panel */}
<AnimatePresence>
{detailsOpen && (
<motion.aside
id={detailsId}
key="details"
aria-label="Chat details"
initial={reduce ? { opacity: 0 } : { x: "100%" }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: "100%" }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
setDetailsOpen(false);
detailsBtnRef.current?.focus();
}
}}
className="absolute inset-y-0 right-0 z-20 w-full max-w-[17rem] overflow-y-auto border-l border-slate-200 bg-white p-4 shadow-2xl shadow-slate-900/10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/50"
>
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Details
</h3>
<button
type="button"
className={iconBtn}
onClick={() => {
setDetailsOpen(false);
detailsBtnRef.current?.focus();
}}
>
<IconClose />
<span className="sr-only">Close chat details</span>
</button>
</div>
<dl className="mt-4 space-y-2 text-[13px]">
<div className="flex justify-between gap-2">
<dt className="text-slate-500 dark:text-slate-400">Created</dt>
<dd className="text-slate-800 dark:text-slate-200">14 Feb 2026</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-slate-500 dark:text-slate-400">Messages</dt>
<dd className="tabular-nums text-slate-800 dark:text-slate-200">1,284</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-slate-500 dark:text-slate-400">Shared files</dt>
<dd className="tabular-nums text-slate-800 dark:text-slate-200">37</dd>
</div>
</dl>
<h4 className="mt-5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Members
</h4>
<ul className="mt-2 space-y-2.5">
{MEMBERS.map((m) => (
<li key={m.id} className="flex items-center gap-2.5">
<span className="relative shrink-0">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br ${m.ring} text-[10px] font-semibold text-white`}
>
{m.initials}
</span>
<span
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-slate-900 ${PRESENCE_DOT[m.presence]}`}
/>
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium text-slate-800 dark:text-slate-200">
{m.name}
</span>
<span className="block truncate text-[11px] text-slate-500 dark:text-slate-400">
{m.role} · {PRESENCE_LABEL[m.presence]}
</span>
</span>
</li>
))}
</ul>
</motion.aside>
)}
</AnimatePresence>
</div>
{/* composer stub keeps the header in context */}
<div className="border-t border-slate-200 px-3 py-2.5 sm:px-4 dark:border-slate-800">
<p className="text-xs text-slate-400 dark:text-slate-500">
Composer sits here — the header above owns search, calls, details and the overflow menu.
</p>
</div>
</div>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Support Widget
Originalfloating support chat widget

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

