Chat Group
Original · freegroup chat with member names
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-group.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type MemberId = "priya" | "diego" | "amara" | "tomas" | "you";
type Member = {
id: MemberId;
name: string;
handle: string;
role: string;
initials: string;
ring: string;
dot: string;
tint: string;
status: "online" | "away" | "offline";
};
type Message = {
id: number;
author: MemberId;
body: string;
at: string;
reactions: Partial<Record<"fire" | "eyes" | "plus", MemberId[]>>;
pending?: boolean;
};
const MEMBERS: Record<MemberId, Member> = {
priya: {
id: "priya",
name: "Priya Raghavan",
handle: "@priya",
role: "Design lead",
initials: "PR",
ring: "ring-violet-400/60 dark:ring-violet-400/40",
dot: "bg-violet-500",
tint: "bg-violet-100 text-violet-900 dark:bg-violet-500/15 dark:text-violet-200",
status: "online",
},
diego: {
id: "diego",
name: "Diego Ferreira",
handle: "@diego",
role: "Backend",
initials: "DF",
ring: "ring-emerald-400/60 dark:ring-emerald-400/40",
dot: "bg-emerald-500",
tint: "bg-emerald-100 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-200",
status: "online",
},
amara: {
id: "amara",
name: "Amara Osei",
handle: "@amara",
role: "Product",
initials: "AO",
ring: "ring-amber-400/60 dark:ring-amber-400/40",
dot: "bg-amber-500",
tint: "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200",
status: "away",
},
tomas: {
id: "tomas",
name: "Tomás Lindqvist",
handle: "@tomas",
role: "QA",
initials: "TL",
ring: "ring-sky-400/60 dark:ring-sky-400/40",
dot: "bg-sky-500",
tint: "bg-sky-100 text-sky-900 dark:bg-sky-500/15 dark:text-sky-200",
status: "offline",
},
you: {
id: "you",
name: "You",
handle: "@sal",
role: "Engineering",
initials: "SA",
ring: "ring-indigo-400/60 dark:ring-indigo-400/40",
dot: "bg-indigo-500",
tint: "bg-indigo-100 text-indigo-900 dark:bg-indigo-500/15 dark:text-indigo-200",
status: "online",
},
};
const ORDER: MemberId[] = ["priya", "diego", "amara", "tomas", "you"];
const REACTIONS: { key: "fire" | "eyes" | "plus"; glyph: string; label: string }[] = [
{ key: "fire", glyph: "🔥", label: "Fire" },
{ key: "eyes", glyph: "👀", label: "Looking into it" },
{ key: "plus", glyph: "➕", label: "Plus one" },
];
const SEED: Message[] = [
{
id: 1,
author: "amara",
body: "Staging is on build 412. The checkout step still drops the coupon field when you go back a page.",
at: "09:41",
reactions: { eyes: ["tomas"] },
},
{
id: 2,
author: "diego",
body: "That's on me — the session cache clears on the back nav. Patch is written, I just want Tomás to hammer it first.",
at: "09:43",
reactions: { plus: ["amara", "priya"] },
},
{
id: 3,
author: "tomas",
body: "Give me the branch name and I'll run the regression suite against it this afternoon.",
at: "09:44",
reactions: {},
},
{
id: 4,
author: "priya",
body: "While we're in there: the coupon input has a 34px tap target on mobile. Bumping it to 44 costs nothing and fixes half the complaints.",
at: "09:47",
reactions: { fire: ["amara"], plus: ["diego"] },
},
];
const STATUS_LABEL: Record<Member["status"], string> = {
online: "Online",
away: "Away",
offline: "Offline",
};
function StatusDot({ status }: { status: Member["status"] }) {
const cls =
status === "online"
? "bg-emerald-500"
: status === "away"
? "bg-amber-500"
: "bg-slate-400 dark:bg-slate-600";
return (
<span
aria-hidden="true"
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-slate-950 ${cls}`}
/>
);
}
function Avatar({ member, size = "md" }: { member: Member; size?: "sm" | "md" }) {
const box = size === "sm" ? "h-7 w-7 text-[10px]" : "h-9 w-9 text-xs";
return (
<span className="relative inline-flex shrink-0">
<span
aria-hidden="true"
className={`inline-flex items-center justify-center rounded-full font-semibold tracking-tight ring-2 ${box} ${member.ring} ${member.tint}`}
>
{member.initials}
</span>
<StatusDot status={member.status} />
</span>
);
}
function SendIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none">
<path
d="M3.4 9.6 16.2 3.8a.5.5 0 0 1 .67.67L11.06 17.3a.5.5 0 0 1-.93-.05l-1.6-5.1-5.1-1.6a.5.5 0 0 1-.03-.95Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5" fill="none">
<path d="m5.5 5.5 9 9m0-9-9 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function PeopleIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none">
<circle cx="7.5" cy="6.5" r="2.75" stroke="currentColor" strokeWidth="1.4" />
<path d="M2.5 16c0-2.5 2.24-4.25 5-4.25S12.5 13.5 12.5 16" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M13.75 4.2a2.75 2.75 0 0 1 0 5.1M14.5 11.9c2.03.42 3.5 1.94 3.5 4.1" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
export default function ChatGroup() {
const reduced = useReducedMotion();
const [messages, setMessages] = useState<Message[]>(SEED);
const [draft, setDraft] = useState("");
const [rosterOpen, setRosterOpen] = useState(false);
const [typing, setTyping] = useState<MemberId | null>(null);
const [live, setLive] = useState("");
const nextId = useRef(SEED.length + 1);
const logRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const rosterBtnRef = useRef<HTMLButtonElement | null>(null);
const rosterRef = useRef<HTMLDivElement | null>(null);
const timers = useRef<number[]>([]);
const sendSeq = useRef(0);
const rosterId = useId();
const logId = useId();
const online = useMemo(() => ORDER.filter((id) => MEMBERS[id].status === "online").length, []);
useEffect(() => {
const el = logRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
}, [messages, typing, reduced]);
useEffect(() => {
const t = timers.current;
return () => {
t.forEach((n) => window.clearTimeout(n));
};
}, []);
useEffect(() => {
if (!rosterOpen) return;
rosterRef.current?.focus();
}, [rosterOpen]);
useEffect(() => {
if (!rosterOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setRosterOpen(false);
rosterBtnRef.current?.focus();
return;
}
if (e.key !== "Tab") return;
const panel = rosterRef.current;
if (!panel) return;
const nodes = Array.from(panel.querySelectorAll<HTMLElement>("button:not([disabled])"));
if (nodes.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || active === panel) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
const onClick = (e: MouseEvent) => {
const t = e.target as Node;
if (rosterRef.current?.contains(t) || rosterBtnRef.current?.contains(t)) return;
setRosterOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClick);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClick);
};
}, [rosterOpen]);
const toggleReaction = useCallback((messageId: number, key: "fire" | "eyes" | "plus") => {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== messageId) return m;
const current = m.reactions[key] ?? [];
const has = current.includes("you");
const next = has ? current.filter((x) => x !== "you") : [...current, "you"];
const reactions = { ...m.reactions, [key]: next };
if (next.length === 0) delete reactions[key];
return { ...m, reactions };
}),
);
}, []);
const send = useCallback(() => {
const body = draft.trim();
if (!body) return;
const seq = ++sendSeq.current;
const id = nextId.current++;
const at = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
setMessages((prev) => [...prev, { id, author: "you", body, at, reactions: {}, pending: true }]);
setDraft("");
setLive("Message sent to Ship Room.");
const t1 = window.setTimeout(() => {
setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, pending: false } : m)));
}, 620);
const responder: MemberId = id % 2 === 0 ? "priya" : "diego";
const t2 = window.setTimeout(() => setTyping(responder), 900);
const t3 = window.setTimeout(() => {
if (sendSeq.current === seq) setTyping(null);
const replyId = nextId.current++;
const reply =
responder === "priya"
? "Noted — I'll drop the spacing spec in the thread so nobody has to guess the values."
: "Works for me. I'll tag you on the PR once the tests go green.";
setMessages((prev) => [
...prev,
{
id: replyId,
author: responder,
body: reply,
at: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }),
reactions: {},
},
]);
setLive(`${MEMBERS[responder].name} replied.`);
}, 2400);
timers.current.push(t1, t2, t3);
}, [draft]);
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes cgrp-typing-bob {
0%, 60%, 100% { transform: translateY(0); opacity: .45; }
30% { transform: translateY(-3px); opacity: 1; }
}
@keyframes cgrp-pulse-ring {
0% { transform: scale(1); opacity: .55; }
70% { transform: scale(2.2); opacity: 0; }
100% { transform: scale(2.2); opacity: 0; }
}
.cgrp-dot { animation: cgrp-typing-bob 1.15s ease-in-out infinite; }
.cgrp-dot:nth-child(2) { animation-delay: .15s; }
.cgrp-dot:nth-child(3) { animation-delay: .3s; }
.cgrp-ping { animation: cgrp-pulse-ring 2.4s cubic-bezier(0, 0, .2, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
.cgrp-dot, .cgrp-ping { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<header className="mb-6">
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Ship Room
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Five people, one release. Reactions, roster and replies all work — press{" "}
<kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
Enter
</kbd>{" "}
to send.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Header bar */}
<div className="relative flex items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div aria-hidden="true" className="flex -space-x-2">
{ORDER.slice(0, 4).map((id) => (
<span
key={id}
title={MEMBERS[id].name}
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-[10px] font-semibold ring-2 ring-white dark:ring-slate-900 ${MEMBERS[id].tint}`}
>
{MEMBERS[id].initials}
</span>
))}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
#ship-room
</p>
<p className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="cgrp-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{online} of {ORDER.length} online
</p>
</div>
<button
ref={rosterBtnRef}
type="button"
onClick={() => setRosterOpen((v) => !v)}
aria-expanded={rosterOpen}
aria-controls={rosterId}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<PeopleIcon />
Members
</button>
<AnimatePresence>
{rosterOpen ? (
<motion.div
ref={rosterRef}
id={rosterId}
role="dialog"
aria-modal="true"
aria-label="Members of #ship-room"
tabIndex={-1}
initial={reduced ? false : { opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute right-3 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800"
>
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Members · {ORDER.length}
</p>
<button
type="button"
onClick={() => {
setRosterOpen(false);
rosterBtnRef.current?.focus();
}}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
>
<CloseIcon />
<span className="sr-only">Close members list</span>
</button>
</div>
<ul className="space-y-0.5">
{ORDER.map((id) => {
const m = MEMBERS[id];
return (
<li key={id} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5">
<Avatar member={m} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{m.name}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">
{m.handle} · {m.role}
</span>
</span>
<span className="shrink-0 text-[11px] font-medium text-slate-500 dark:text-slate-400">
{STATUS_LABEL[m.status]}
</span>
</li>
);
})}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
{/* Message log */}
<div
ref={logRef}
id={logId}
role="log"
aria-label="Ship Room messages"
aria-live="polite"
tabIndex={0}
className="h-[26rem] space-y-4 overflow-y-auto px-4 py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
>
{messages.map((m, i) => {
const member = MEMBERS[m.author];
const mine = m.author === "you";
const prev = messages[i - 1];
const grouped = prev?.author === m.author;
return (
<motion.article
key={m.id}
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className={`flex gap-3 ${mine ? "flex-row-reverse" : ""}`}
>
<span className={grouped ? "invisible" : ""}>
<Avatar member={member} />
</span>
<div className={`min-w-0 max-w-[82%] ${mine ? "items-end text-right" : ""} flex flex-col`}>
{!grouped ? (
<p className={`mb-1 flex items-baseline gap-2 text-xs ${mine ? "flex-row-reverse" : ""}`}>
<span className="font-semibold text-slate-900 dark:text-slate-100">{member.name}</span>
<span className="text-slate-400 dark:text-slate-500">{member.handle}</span>
<span className="text-slate-400 dark:text-slate-500">{m.at}</span>
</p>
) : null}
<div
className={`inline-block rounded-2xl px-3.5 py-2.5 text-left text-sm leading-relaxed transition-opacity ${
mine
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200"
} ${m.pending ? "opacity-60" : "opacity-100"}`}
>
{m.body}
</div>
<div className={`mt-1.5 flex flex-wrap items-center gap-1 ${mine ? "justify-end" : ""}`}>
{REACTIONS.map((r) => {
const who = m.reactions[r.key] ?? [];
const active = who.includes("you");
const show = who.length > 0;
const names = who.map((id) => MEMBERS[id].name).join(", ");
return (
<button
key={r.key}
type="button"
onClick={() => toggleReaction(m.id, r.key)}
aria-pressed={active}
aria-label={
show
? `${r.label}, ${who.length} ${who.length === 1 ? "person" : "people"}: ${names}`
: `React with ${r.label}`
}
title={show ? names : r.label}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs leading-5 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
active
? "border-indigo-400 bg-indigo-50 text-indigo-700 dark:border-indigo-400/50 dark:bg-indigo-500/15 dark:text-indigo-200"
: show
? "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600"
: "border-transparent text-slate-400 opacity-50 hover:bg-slate-100 hover:opacity-100 focus-visible:opacity-100 dark:text-slate-500 dark:hover:bg-slate-800"
}`}
>
<span aria-hidden="true">{r.glyph}</span>
{show ? <span className="tabular-nums font-medium">{who.length}</span> : null}
</button>
);
})}
</div>
</div>
</motion.article>
);
})}
<AnimatePresence>
{typing ? (
<motion.div
key="typing"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="flex items-center gap-3"
>
<Avatar member={MEMBERS[typing]} />
<span className="inline-flex items-center gap-1 rounded-2xl bg-slate-100 px-3.5 py-3 dark:bg-slate-800">
<span className="sr-only">{MEMBERS[typing].name} is typing</span>
<span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
<span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
<span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
</span>
</motion.div>
) : null}
</AnimatePresence>
</div>
{/* Composer */}
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="border-t border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex items-end gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2 transition focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/30 dark:border-slate-700 dark:bg-slate-950/40 dark:focus-within:border-indigo-400">
<label htmlFor={`${logId}-composer`} className="sr-only">
Message #ship-room
</label>
<textarea
id={`${logId}-composer`}
ref={inputRef}
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder="Message #ship-room…"
className="max-h-28 min-h-[2.25rem] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={draft.trim().length === 0}
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg bg-indigo-600 px-3 text-sm font-medium text-white transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-slate-900"
>
<SendIcon />
Send
</button>
</div>
<p className="mt-2 px-1 text-[11px] text-slate-400 dark:text-slate-500">
Shift + Enter for a new line. Everyone in #ship-room sees this.
</p>
</form>
</div>
<p aria-live="polite" className="sr-only">
{live}
</p>
</div>
</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 List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

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.

