Chat Reactions
Original ยท freemessage reactions and replies
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-reactions.json"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type FormEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Reaction = {
emoji: string;
label: string;
count: number;
mine: boolean;
};
type Message = {
id: string;
author: string;
initials: string;
mine: boolean;
time: string;
text: string;
replyToId?: string;
reactions: Reaction[];
};
const PALETTE: { emoji: string; label: string }[] = [
{ emoji: "๐", label: "Thumbs up" },
{ emoji: "๐", label: "Celebrate" },
{ emoji: "โค๏ธ", label: "Heart" },
{ emoji: "๐", label: "Laugh" },
{ emoji: "๐", label: "Eyes on it" },
{ emoji: "๐", label: "Ship it" },
];
const SEED: Message[] = [
{
id: "m1",
author: "Priya Raman",
initials: "PR",
mine: false,
time: "10:12",
text: "Staging is green. Checkout went from four steps to two and the drop-off on the address screen is gone.",
reactions: [
{ emoji: "๐", label: "Celebrate", count: 3, mine: false },
{ emoji: "๐", label: "Ship it", count: 1, mine: false },
],
},
{
id: "m2",
author: "You",
initials: "YO",
mine: true,
time: "10:14",
text: "Ran it on a throttled Moto G. Interaction delay is 180ms now, down from 640ms.",
reactions: [{ emoji: "๐", label: "Thumbs up", count: 2, mine: false }],
},
{
id: "m3",
author: "Dev Kapoor",
initials: "DK",
mine: false,
time: "10:15",
text: "That number is the whole quarter in one line. Can I quote it in the Friday review?",
replyToId: "m2",
reactions: [],
},
{
id: "m4",
author: "Priya Raman",
initials: "PR",
mine: false,
time: "10:17",
text: "Quote it. Just note it is a cold load โ warm loads sit around 90ms.",
reactions: [{ emoji: "๐", label: "Eyes on it", count: 1, mine: true }],
},
];
function avatarTone(mine: boolean): string {
return mine
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300";
}
export default function ChatReactions() {
const reduced = useReducedMotion();
const headingId = useId();
const composerId = useId();
const [messages, setMessages] = useState<Message[]>(SEED);
const [pickerFor, setPickerFor] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<string | null>(null);
const [draft, setDraft] = useState<string>("");
const [flashId, setFlashId] = useState<string | null>(null);
const [announce, setAnnounce] = useState<string>("");
const nextId = useRef<number>(0);
const flashTimer = useRef<number | null>(null);
const nodeMap = useRef<Record<string, HTMLLIElement | null>>({});
const triggerMap = useRef<Record<string, HTMLButtonElement | null>>({});
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const listRef = useRef<HTMLOListElement | null>(null);
useEffect(() => {
return () => {
if (flashTimer.current !== null) window.clearTimeout(flashTimer.current);
};
}, []);
const findMessage = useCallback(
(id: string | null | undefined): Message | undefined =>
id ? messages.find((m) => m.id === id) : undefined,
[messages],
);
const toggleReaction = useCallback(
(messageId: string, emoji: string, label: string) => {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== messageId) return m;
const existing = m.reactions.find((r) => r.emoji === emoji);
if (!existing) {
setAnnounce(`Added ${label} reaction to message from ${m.author}`);
return {
...m,
reactions: [...m.reactions, { emoji, label, count: 1, mine: true }],
};
}
const nextCount = existing.mine ? existing.count - 1 : existing.count + 1;
setAnnounce(
existing.mine
? `Removed ${label} reaction from message by ${m.author}`
: `Added ${label} reaction to message from ${m.author}`,
);
if (nextCount <= 0) {
return { ...m, reactions: m.reactions.filter((r) => r.emoji !== emoji) };
}
return {
...m,
reactions: m.reactions.map((r) =>
r.emoji === emoji ? { ...r, count: nextCount, mine: !r.mine } : r,
),
};
}),
);
},
[],
);
const closePicker = useCallback(
(restoreFocus: boolean) => {
const openId = pickerFor;
setPickerFor(null);
if (restoreFocus && openId) {
triggerMap.current[openId]?.focus();
}
},
[pickerFor],
);
const jumpTo = useCallback((id: string) => {
const node = nodeMap.current[id];
if (!node) return;
node.scrollIntoView({ block: "nearest", behavior: "auto" });
setFlashId(id);
if (flashTimer.current !== null) window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashId(null), 1300);
}, []);
const startReply = useCallback((id: string) => {
setReplyTo(id);
setPickerFor(null);
composerRef.current?.focus();
}, []);
const send = useCallback(
(event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const text = draft.trim();
if (!text) return;
nextId.current += 1;
const stamp = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const message: Message = {
id: `sent-${nextId.current}`,
author: "You",
initials: "YO",
mine: true,
time: stamp,
text,
replyToId: replyTo ?? undefined,
reactions: [],
};
setMessages((prev) => [...prev, message]);
setDraft("");
setReplyTo(null);
setAnnounce("Message sent");
window.requestAnimationFrame(() => {
const list = listRef.current;
if (list) list.scrollTop = list.scrollHeight;
});
},
[draft, replyTo],
);
const replyTarget = findMessage(replyTo);
return (
<section
aria-labelledby={headingId}
className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-zinc-950 dark:text-slate-100"
>
<style>{`
@keyframes crx-pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes crx-flash {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
25% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.45); }
75% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25); }
}
@keyframes crx-breathe {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.82); }
}
.crx-pop { animation: crx-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.crx-flash { animation: crx-flash 1250ms ease-out both; border-radius: 1rem; }
.crx-breathe { animation: crx-breathe 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.crx-pop, .crx-flash, .crx-breathe { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Threaded conversation
</p>
<h2
id={headingId}
className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
>
React and reply without losing the thread
</h2>
<p className="mt-3 max-w-prose text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Hover or focus a message to react or quote it. Every control is keyboard reachable โ
arrow keys move through the emoji picker, Escape closes it.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-zinc-800">
<span
aria-hidden="true"
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-slate-900 text-xs font-semibold text-white dark:bg-white dark:text-zinc-900"
>
#
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
checkout-rebuild
</p>
<p className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full bg-emerald-500 ${reduced ? "" : "crx-breathe"}`}
/>
3 members online
</p>
</div>
</div>
<ol
ref={listRef}
className="max-h-[26rem] space-y-1 overflow-y-auto px-3 py-4 sm:px-4"
>
<AnimatePresence initial={false}>
{messages.map((message) => {
const quoted = findMessage(message.replyToId);
const isOpen = pickerFor === message.id;
return (
<motion.li
key={message.id}
ref={(node: HTMLLIElement | null) => {
nodeMap.current[message.id] = node;
}}
layout={!reduced}
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduced ? { duration: 0 } : { duration: 0.22, ease: "easeOut" }}
className={`group relative rounded-2xl p-2 transition-colors ${
flashId === message.id ? "crx-flash bg-indigo-50 dark:bg-indigo-500/10" : ""
}`}
>
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full text-[11px] font-semibold ${avatarTone(
message.mine,
)}`}
>
{message.initials}
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{message.author}
</span>
<span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{message.time}
</span>
</p>
{quoted ? (
<button
type="button"
onClick={() => jumpTo(quoted.id)}
className="mt-1.5 flex w-full max-w-md items-start gap-2 rounded-lg border-l-2 border-indigo-400 bg-slate-100/70 px-2.5 py-1.5 text-left transition-colors hover:bg-slate-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-indigo-500 dark:bg-zinc-800/70 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="mt-0.5 h-3 w-3 shrink-0 text-indigo-500 dark:text-indigo-400"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6.5 3 2.5 7l4 4" />
<path d="M2.5 7h7a4 4 0 0 1 4 4v2" />
</svg>
<span className="min-w-0">
<span className="block text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
{quoted.author}
</span>
<span className="line-clamp-1 block text-xs text-slate-500 dark:text-slate-400">
{quoted.text}
</span>
</span>
<span className="sr-only">Jump to quoted message</span>
</button>
) : null}
<p className="mt-1 max-w-prose text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{message.text}
</p>
{message.reactions.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
<AnimatePresence initial={false}>
{message.reactions.map((reaction) => (
<motion.li
key={reaction.emoji}
layout={!reduced}
initial={reduced ? false : { scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={reduced ? { opacity: 0 } : { scale: 0.7, opacity: 0 }}
transition={reduced ? { duration: 0 } : { duration: 0.16 }}
>
<button
type="button"
aria-pressed={reaction.mine}
onClick={() =>
toggleReaction(message.id, reaction.emoji, reaction.label)
}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium tabular-nums transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
reaction.mine
? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/50 dark:bg-indigo-500/15 dark:text-indigo-300"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-slate-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-700"
}`}
>
<span aria-hidden="true">{reaction.emoji}</span>
<span aria-hidden="true">{reaction.count}</span>
<span className="sr-only">
{reaction.label}, {reaction.count}{" "}
{reaction.count === 1 ? "person" : "people"}.{" "}
{reaction.mine ? "You reacted." : "React."}
</span>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
) : null}
</div>
<div className="relative flex shrink-0 items-center gap-0.5 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
<button
ref={(node: HTMLButtonElement | null) => {
triggerMap.current[message.id] = node;
}}
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label={`Add a reaction to the message from ${message.author}`}
onClick={() => setPickerFor(isOpen ? null : message.id)}
className="grid h-7 w-7 place-items-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-900 dark:text-slate-400 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<circle cx="10" cy="10" r="7" />
<path d="M7.2 11.6a3.4 3.4 0 0 0 5.6 0" />
<path d="M7.5 8h.01M12.5 8h.01" />
</svg>
</button>
<button
type="button"
aria-label={`Reply to ${message.author}`}
onClick={() => startReply(message.id)}
className="grid h-7 w-7 place-items-center rounded-md border border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-900 dark:text-slate-400 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 4 3 9l5 5" />
<path d="M3 9h8a6 6 0 0 1 6 6v1" />
</svg>
</button>
<AnimatePresence>
{isOpen ? (
<EmojiPicker
reduced={Boolean(reduced)}
active={message.reactions}
onPick={(emoji, label) => {
toggleReaction(message.id, emoji, label);
closePicker(true);
}}
onClose={closePicker}
/>
) : null}
</AnimatePresence>
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ol>
<form
onSubmit={send}
className="border-t border-slate-200 bg-slate-50/60 p-3 sm:p-4 dark:border-zinc-800 dark:bg-zinc-900/60"
>
<AnimatePresence initial={false}>
{replyTarget ? (
<motion.div
key="reply-bar"
initial={reduced ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={reduced ? { duration: 0 } : { duration: 0.18 }}
className="overflow-hidden"
>
<div className="mb-2 flex items-center gap-2 rounded-lg border-l-2 border-indigo-500 bg-white px-2.5 py-1.5 dark:bg-zinc-800">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
Replying to {replyTarget.author}
</p>
<p className="line-clamp-1 text-xs text-slate-500 dark:text-slate-400">
{replyTarget.text}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label="Cancel reply"
className="grid h-6 w-6 shrink-0 place-items-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-zinc-700 dark:hover:text-white"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div className="flex items-end gap-2">
<label htmlFor={composerId} className="sr-only">
Write a message
</label>
<textarea
id={composerId}
ref={composerRef}
rows={1}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
if (event.key === "Escape" && replyTo) {
event.preventDefault();
setReplyTo(null);
}
}}
placeholder="Message #checkout-rebuild โ Enter to send, Shift + Enter for a new line"
className="min-h-[2.5rem] flex-1 resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-zinc-700 dark:bg-zinc-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-zinc-900"
/>
<button
type="submit"
disabled={draft.trim().length === 0}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus: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-zinc-700 dark:disabled:text-zinc-500 dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2 7 9" />
<path d="M14 2 9.5 14 7 9l-5-2.5L14 2Z" />
</svg>
Send
</button>
</div>
</form>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</div>
</section>
);
}
function EmojiPicker({
reduced,
active,
onPick,
onClose,
}: {
reduced: boolean;
active: Reaction[];
onPick: (emoji: string, label: string) => void;
onClose: (restoreFocus: boolean) => void;
}) {
const [index, setIndex] = useState<number>(0);
const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
itemsRef.current[0]?.focus();
}, []);
useEffect(() => {
function onPointerDown(event: PointerEvent) {
const root = rootRef.current;
if (root && event.target instanceof Node && !root.contains(event.target)) {
onClose(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [onClose]);
function move(next: number) {
const bounded = (next + PALETTE.length) % PALETTE.length;
setIndex(bounded);
itemsRef.current[bounded]?.focus();
}
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
move(index + 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
move(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
move(0);
} else if (event.key === "End") {
event.preventDefault();
move(PALETTE.length - 1);
} else if (event.key === "Escape" || event.key === "Tab") {
event.preventDefault();
onClose(true);
}
}
return (
<motion.div
ref={rootRef}
role="menu"
aria-label="Add a reaction"
aria-orientation="horizontal"
onKeyDown={onKeyDown}
initial={reduced ? false : { opacity: 0, y: 6, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.96 }}
transition={reduced ? { duration: 0 } : { duration: 0.15, ease: "easeOut" }}
className="absolute right-0 top-9 z-20 flex gap-0.5 rounded-xl border border-slate-200 bg-white p-1 shadow-lg shadow-slate-900/10 dark:border-zinc-700 dark:bg-zinc-800 dark:shadow-black/40"
>
{PALETTE.map((entry, i) => {
const mine = active.some((r) => r.emoji === entry.emoji && r.mine);
return (
<button
key={entry.emoji}
ref={(node: HTMLButtonElement | null) => {
itemsRef.current[i] = node;
}}
type="button"
role="menuitemcheckbox"
aria-checked={mine}
tabIndex={i === index ? 0 : -1}
onFocus={() => setIndex(i)}
onClick={() => onPick(entry.emoji, entry.label)}
className={`grid h-8 w-8 place-items-center rounded-lg text-base transition-transform hover:scale-110 focus: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-zinc-800 ${
mine
? "bg-indigo-50 dark:bg-indigo-500/20"
: "hover:bg-slate-100 dark:hover:bg-zinc-700"
} ${reduced ? "" : "crx-pop"}`}
>
<span aria-hidden="true">{entry.emoji}</span>
<span className="sr-only">{entry.label}</span>
</button>
);
})}
</motion.div>
);
}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 Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Read Receipts
Originalread receipts and timestamps

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

