Chat Conversation
Original Β· freefull conversation thread with avatars
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-conversation.json"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type FormEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Author = {
id: string;
name: string;
role: string;
initials: string;
avatar: string;
online: boolean;
};
type Message = {
id: string;
authorId: string;
text: string;
time: string;
reactions: Record<string, number>;
};
const AUTHORS: Record<string, Author> = {
maya: {
id: "maya",
name: "Maya Ellison",
role: "Product Design Lead",
initials: "ME",
avatar: "from-violet-500 to-indigo-600",
online: true,
},
theo: {
id: "theo",
name: "Theo Nakamura",
role: "Staff Engineer",
initials: "TN",
avatar: "from-sky-500 to-emerald-600",
online: true,
},
you: {
id: "you",
name: "You",
role: "Engineering Manager",
initials: "YO",
avatar: "from-indigo-500 to-violet-600",
online: true,
},
};
const SEED: Message[] = [
{
id: "m1",
authorId: "maya",
text: "Pushed the checkout revision to Figma β page 3 has the one-step variant we argued about on Tuesday.",
time: "9:02",
reactions: {},
},
{
id: "m2",
authorId: "maya",
text: "The address form is collapsed by default now. That's roughly 400px of scroll gone on a mid-size phone.",
time: "9:02",
reactions: { "π": 2 },
},
{
id: "m3",
authorId: "theo",
text: "Looked at it. One-step is fine on our side β address autocomplete resolves before the user stops typing, so collapsing it doesn't cost us a round trip.",
time: "9:14",
reactions: { "π": 3 },
},
{
id: "m4",
authorId: "theo",
text: "One problem: we can't validate the postcode until a country is picked. If country sits inside the collapsed block, error states arrive out of nowhere.",
time: "9:15",
reactions: {},
},
{
id: "m5",
authorId: "you",
text: "Good catch. Maya β can country move above the collapse?",
time: "9:21",
reactions: {},
},
{
id: "m6",
authorId: "maya",
text: "Yes. I'll lift country and postcode into the always-visible row. Street and unit stay inside the collapse, which is the part nobody rereads anyway.",
time: "9:26",
reactions: { "π": 1, "π": 2 },
},
{
id: "m7",
authorId: "theo",
text: "Works for me. I'll fire validation on blur instead of keystroke so it stops shouting at people mid-typing.",
time: "9:30",
reactions: {},
},
];
const REPLIES: { authorId: string; text: string }[] = [
{
authorId: "maya",
text: "Noted β updated frames will be in this thread before standup tomorrow.",
},
{
authorId: "theo",
text: "Makes sense. I'll put it on the checkout branch and ping you once staging rebuilds.",
},
{
authorId: "maya",
text: "Agreed. Keep the collapse, but country and postcode live outside it.",
},
];
const EMOJI = ["π", "π", "π", "β
"];
function ReactionPicker({
reduced,
picked,
align,
onPick,
onClose,
}: {
reduced: boolean;
picked: string[];
align: "left" | "right";
onPick: (emoji: string) => void;
onClose: (restoreFocus: boolean) => void;
}) {
const [index, setIndex] = useState(0);
const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
itemsRef.current[0]?.focus();
}, []);
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
const root = rootRef.current;
if (root && e.target instanceof Node && !root.contains(e.target)) {
onClose(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [onClose]);
const move = (next: number) => {
const bounded = (next + EMOJI.length) % EMOJI.length;
setIndex(bounded);
itemsRef.current[bounded]?.focus();
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(index + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(index - 1);
} else if (e.key === "Home") {
e.preventDefault();
move(0);
} else if (e.key === "End") {
e.preventDefault();
move(EMOJI.length - 1);
} else if (e.key === "Escape" || e.key === "Tab") {
e.preventDefault();
onClose(true);
}
};
return (
<motion.div
ref={rootRef}
role="menu"
aria-label="Pick a reaction"
aria-orientation="horizontal"
onKeyDown={onKeyDown}
initial={reduced ? false : { opacity: 0, y: 6, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: reduced ? 0 : 0.15, ease: "easeOut" }}
className={`absolute bottom-9 z-10 flex gap-0.5 rounded-full border border-slate-200 bg-white p-1 shadow-lg dark:border-slate-700 dark:bg-slate-800 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{EMOJI.map((emo, i) => (
<button
key={emo}
ref={(node: HTMLButtonElement | null) => {
itemsRef.current[i] = node;
}}
type="button"
role="menuitemcheckbox"
aria-checked={picked.includes(emo)}
aria-label={`React with ${emo}`}
tabIndex={i === index ? 0 : -1}
onFocus={() => setIndex(i)}
onClick={() => onPick(emo)}
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm 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-800 ${
picked.includes(emo)
? "bg-indigo-50 dark:bg-indigo-500/20"
: "hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
<span aria-hidden="true">{emo}</span>
</button>
))}
</motion.div>
);
}
function Avatar({ author, size }: { author: Author; size: "sm" | "md" }) {
const box = size === "sm" ? "h-8 w-8 text-[11px]" : "h-9 w-9 text-xs";
return (
<span className="relative inline-flex shrink-0">
<span
aria-hidden="true"
className={`inline-flex ${box} items-center justify-center rounded-full bg-gradient-to-br ${author.avatar} font-semibold tracking-wide text-white ring-2 ring-white dark:ring-slate-900`}
>
{author.initials}
</span>
{author.online ? (
<span
aria-hidden="true"
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900"
/>
) : null}
</span>
);
}
export default function ChatConversation() {
const reduced = useReducedMotion();
const uid = useId();
const [messages, setMessages] = useState<Message[]>(SEED);
const [draft, setDraft] = useState("");
const [typingId, setTypingId] = useState<string | null>(null);
const [pickerFor, setPickerFor] = useState<string | null>(null);
const [mine, setMine] = useState<Record<string, string[]>>({});
const [atBottom, setAtBottom] = useState(true);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const triggers = useRef<Record<string, HTMLButtonElement | null>>({});
const timers = useRef<number[]>([]);
const replyIdx = useRef(0);
const seq = useRef(0);
useEffect(() => {
const list = timers.current;
return () => {
list.forEach((t) => window.clearTimeout(t));
};
}, []);
useEffect(() => {
const node = sentinelRef.current;
const root = scrollRef.current;
if (!node || !root) return;
const io = new IntersectionObserver(
(entries) => setAtBottom(entries[0].isIntersecting),
{ root, threshold: 0.99 },
);
io.observe(node);
return () => io.disconnect();
}, []);
const scrollToEnd = useCallback(
(smooth: boolean) => {
const root = scrollRef.current;
if (!root) return;
root.scrollTo({
top: root.scrollHeight,
behavior: smooth && !reduced ? "smooth" : "auto",
});
},
[reduced],
);
useEffect(() => {
if (atBottom || typingId) scrollToEnd(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length, typingId]);
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, 132)}px`;
}, [draft]);
const closePicker = useCallback(
(restoreFocus: boolean) => {
if (restoreFocus && pickerFor) triggers.current[pickerFor]?.focus();
setPickerFor(null);
},
[pickerFor],
);
const clock = () => {
const d = new Date();
const h = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12;
return `${h}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const send = (e: FormEvent) => {
e.preventDefault();
const text = draft.trim();
if (!text) return;
seq.current += 1;
const id = `s${seq.current}`;
setMessages((prev) => [
...prev,
{ id, authorId: "you", text, time: clock(), reactions: {} },
]);
setDraft("");
setAtBottom(true);
const reply = REPLIES[replyIdx.current % REPLIES.length];
replyIdx.current += 1;
const t1 = window.setTimeout(() => setTypingId(reply.authorId), 500);
const t2 = window.setTimeout(() => {
setTypingId(null);
seq.current += 1;
setMessages((prev) => [
...prev,
{
id: `s${seq.current}`,
authorId: reply.authorId,
text: reply.text,
time: clock(),
reactions: {},
},
]);
}, 2300);
timers.current.push(t1, t2);
};
const onDraftKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
const toggleReaction = (msgId: string, emoji: string) => {
setMine((prev) => {
const list = prev[msgId] ?? [];
const next = list.includes(emoji)
? list.filter((x) => x !== emoji)
: [...list, emoji];
return { ...prev, [msgId]: next };
});
};
const countFor = (m: Message, emoji: string) =>
(m.reactions[emoji] ?? 0) + ((mine[m.id] ?? []).includes(emoji) ? 1 : 0);
const keysFor = (m: Message) => {
const set = new Set([...Object.keys(m.reactions), ...(mine[m.id] ?? [])]);
return [...set].filter((k) => countFor(m, k) > 0);
};
const roster = [AUTHORS.maya, AUTHORS.theo, AUTHORS.you];
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 cchat-dot {
0%, 60%, 100% { transform: translateY(0); opacity: .45; }
30% { transform: translateY(-3px); opacity: 1; }
}
@keyframes cchat-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.cchat-dot { animation: cchat-dot 1.2s ease-in-out infinite; }
.cchat-typing { animation: cchat-rise .28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.cchat-dot, .cchat-typing { animation: none !important; }
.cchat-scroll { scroll-behavior: auto !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<div className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
3 online
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
#checkout-redesign
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Thread on the one-step address form. Press Enter to send, Shift +
Enter for a new line.
</p>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3 sm:px-5 dark:border-slate-800">
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{roster.map((a) => (
<span key={a.id} className="inline-flex">
<Avatar author={a} size="sm" />
</span>
))}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
Maya, Theo and you
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
Last activity 9:30 Β· Q3 checkout
</p>
</div>
</div>
<span className="hidden shrink-0 rounded-md border border-slate-200 px-2 py-1 text-[11px] font-medium text-slate-500 sm:inline dark:border-slate-700 dark:text-slate-400">
{messages.length} messages
</span>
</div>
<div className="relative">
<div
ref={scrollRef}
className="cchat-scroll h-[26rem] overflow-y-auto px-3 py-4 sm:px-5"
>
<ol
className="space-y-1"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="Conversation messages"
>
{messages.map((m, i) => {
const author = AUTHORS[m.authorId];
const own = m.authorId === "you";
const prev = messages[i - 1];
const grouped = prev?.authorId === m.authorId;
const chips = keysFor(m);
const open = pickerFor === m.id;
return (
<motion.li
key={m.id}
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.24, ease: "easeOut" }}
className={`group flex gap-3 ${grouped ? "pt-0.5" : "pt-4"} ${
own ? "flex-row-reverse" : ""
}`}
>
<div className="w-9 shrink-0">
{!grouped ? <Avatar author={author} size="md" /> : null}
</div>
<div
className={`flex min-w-0 max-w-[85%] flex-col ${
own ? "items-end" : "items-start"
}`}
>
{!grouped ? (
<div
className={`mb-1 flex items-baseline gap-2 ${
own ? "flex-row-reverse" : ""
}`}
>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{author.name}
</span>
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{m.time}
</span>
</div>
) : null}
<div
className={`flex items-center gap-1 ${
own ? "flex-row-reverse" : ""
}`}
>
<div
className={`rounded-2xl px-3.5 py-2 text-sm leading-relaxed ${
own
? "rounded-tr-sm bg-indigo-600 text-white dark:bg-indigo-500"
: "rounded-tl-sm bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200"
}`}
>
{m.text}
</div>
<div className="relative">
<button
ref={(node: HTMLButtonElement | null) => {
triggers.current[m.id] = node;
}}
type="button"
aria-label={`React to message from ${author.name}`}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setPickerFor(open ? null : m.id)}
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 transition-opacity hover:bg-slate-100 hover:text-slate-600 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:opacity-0 sm:group-hover:opacity-100 dark:hover:bg-slate-800 dark:hover:text-slate-300 dark:focus-visible:ring-offset-slate-900 ${
open ? "opacity-100" : ""
}`}
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
>
<circle cx="10" cy="10" r="7.25" />
<path d="M7.4 12.1a3.4 3.4 0 0 0 5.2 0" />
<path d="M7.5 8h.01M12.5 8h.01" />
</svg>
</button>
{open ? (
<ReactionPicker
reduced={Boolean(reduced)}
picked={mine[m.id] ?? []}
align={own ? "right" : "left"}
onPick={(emo) => {
toggleReaction(m.id, emo);
closePicker(true);
}}
onClose={closePicker}
/>
) : null}
</div>
</div>
{chips.length ? (
<div
className={`mt-1 flex flex-wrap gap-1 ${
own ? "justify-end" : ""
}`}
>
{chips.map((emo) => {
const picked = (mine[m.id] ?? []).includes(emo);
return (
<button
key={emo}
type="button"
aria-pressed={picked}
aria-label={`${emo} reaction, ${countFor(m, emo)}${
picked ? ", including yours" : ""
}`}
onClick={() => toggleReaction(m.id, emo)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium tabular-nums 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 ${
picked
? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-300"
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
}`}
>
<span aria-hidden="true">{emo}</span>
{countFor(m, emo)}
</button>
);
})}
</div>
) : null}
</div>
</motion.li>
);
})}
</ol>
{typingId ? (
<div className="cchat-typing mt-4 flex items-center gap-3">
<Avatar author={AUTHORS[typingId]} size="md" />
<div className="flex items-center gap-1 rounded-2xl rounded-tl-sm bg-slate-100 px-3.5 py-3 dark:bg-slate-800">
{[0, 1, 2].map((d) => (
<span
key={d}
className="cchat-dot h-1.5 w-1.5 rounded-full bg-slate-400 dark:bg-slate-500"
style={{ animationDelay: `${d * 0.15}s` }}
/>
))}
</div>
<span className="sr-only">
{AUTHORS[typingId].name} is typing
</span>
</div>
) : null}
<div ref={sentinelRef} className="h-px w-full" />
</div>
{!atBottom ? (
<button
type="button"
onClick={() => scrollToEnd(true)}
className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 shadow-md 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:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 4v12M5 11l5 5 5-5" />
</svg>
Jump to latest
</button>
) : null}
</div>
<form
onSubmit={send}
className="border-t border-slate-200 px-3 py-3 sm:px-5 dark:border-slate-800"
>
<div className="flex items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/20 dark:border-slate-700 dark:bg-slate-800/60">
<Avatar author={AUTHORS.you} size="sm" />
<label htmlFor={`${uid}-draft`} className="sr-only">
Write a message to #checkout-redesign
</label>
<textarea
id={`${uid}-draft`}
ref={inputRef}
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onDraftKey}
placeholder="Reply to Maya and Theoβ¦"
className="max-h-[132px] flex-1 resize-none bg-transparent py-1 text-sm leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={!draft.trim()}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-600 text-white transition-colors 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:bg-slate-300 dark:disabled:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">Send message</span>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.5 10h13M11 4.5 16.5 10 11 15.5" />
</svg>
</button>
</div>
<p className="mt-2 pl-11 text-[11px] text-slate-400 dark:text-slate-500">
Enter to send Β· Shift + Enter for a new line
</p>
</form>
</div>
</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 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 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.

