Comments Reactions
Original · freecomments with emoji reactions
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/comment-reactions.json"use client";
import type { FormEvent, KeyboardEvent } from "react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ReactionKey = "up" | "heart" | "fire" | "eyes" | "think" | "party";
type Reaction = {
key: ReactionKey;
glyph: string;
label: string;
};
const REACTIONS: readonly Reaction[] = [
{ key: "up", glyph: "👍", label: "Agree" },
{ key: "heart", glyph: "❤️", label: "Love this" },
{ key: "fire", glyph: "🔥", label: "Fire" },
{ key: "eyes", glyph: "👀", label: "Watching" },
{ key: "think", glyph: "🤔", label: "Not sure" },
{ key: "party", glyph: "🎉", label: "Celebrate" },
];
const REACTION_BY_KEY: Record<ReactionKey, Reaction> = REACTIONS.reduce(
(acc, r) => {
acc[r.key] = r;
return acc;
},
{} as Record<ReactionKey, Reaction>,
);
type Comment = {
id: string;
author: string;
initials: string;
role: string;
posted: string;
body: string;
accent: string;
counts: Partial<Record<ReactionKey, number>>;
mine: ReactionKey[];
};
const SEED: readonly Comment[] = [
{
id: "c1",
author: "Priya Raghavan",
initials: "PR",
role: "Staff engineer · Platform",
posted: "4 hours ago",
body: "We shipped the same migration last quarter. The part nobody warns you about is that the read replica lag doubles for about 40 minutes while the new index builds. Budget for it, or your p99 will make the on-call rotation very unhappy.",
accent: "indigo",
counts: { up: 24, fire: 6, eyes: 3 },
mine: ["up"],
},
{
id: "c2",
author: "Marcus Oyelaran",
initials: "MO",
role: "Design systems lead",
posted: "2 hours ago",
body: "Counterpoint: the index build is fine if you do it CONCURRENTLY and gate the cutover behind a flag. We ran both schemas side by side for six days and diffed the outputs nightly. Zero customer-visible errors.",
accent: "emerald",
counts: { up: 11, think: 5, heart: 2 },
mine: [],
},
{
id: "c3",
author: "Dana Whitfield",
initials: "DW",
role: "SRE · Reliability",
posted: "47 minutes ago",
body: "Both of you are right and that is the annoying part. Concurrent builds remove the lock, not the I/O. Watch disk throughput, not just lock waits — that is where our last incident actually came from.",
accent: "amber",
counts: { fire: 9, up: 4 },
mine: [],
},
];
const ACCENT_RING: Record<string, string> = {
indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
violet: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
};
function PlusIcon() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
>
<path d="M10 5v10M5 10h10" />
</svg>
);
}
function SmileIcon() {
return (
<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 11.5c.7 1.1 1.75 1.65 3 1.65s2.3-.55 3-1.65" />
<path d="M7.6 7.9h.01M12.4 7.9h.01" strokeWidth={2.2} />
</svg>
);
}
type PickerProps = {
commentId: string;
mine: ReactionKey[];
onPick: (key: ReactionKey) => void;
};
function ReactionPicker({ commentId, mine, onPick }: PickerProps) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const reduce = useReducedMotion();
const wrapRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
const menuId = `${commentId}-reaction-menu`;
const close = useCallback((focusTrigger: boolean) => {
setOpen(false);
if (focusTrigger) triggerRef.current?.focus();
}, []);
const openAt = useCallback((index: number) => {
setActive(index);
setOpen(true);
window.requestAnimationFrame(() => {
optionRefs.current[index]?.focus();
});
}, []);
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openAt(0);
} else if (event.key === "ArrowUp") {
event.preventDefault();
openAt(REACTIONS.length - 1);
}
};
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const last = REACTIONS.length - 1;
if (event.key === "Escape") {
event.preventDefault();
close(true);
return;
}
if (event.key === "Tab") {
close(false);
return;
}
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = active === last ? 0 : active + 1;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = active === 0 ? last : active - 1;
if (event.key === "Home") next = 0;
if (event.key === "End") next = last;
if (next !== null) {
event.preventDefault();
setActive(next);
optionRefs.current[next]?.focus();
}
};
return (
<div
ref={wrapRef}
className="relative"
onBlur={(event) => {
if (!wrapRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false);
}}
>
<button
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label="Add a reaction"
onClick={() => (open ? close(true) : openAt(0))}
onKeyDown={onTriggerKeyDown}
className="inline-flex h-8 items-center gap-1 rounded-full border border-dashed border-slate-300 px-2.5 text-slate-500 transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700 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-600 dark:text-slate-400 dark:hover:border-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-950"
>
<SmileIcon />
<PlusIcon />
</button>
<AnimatePresence>
{open ? (
<motion.div
id={menuId}
role="menu"
aria-label="Choose a reaction"
onKeyDown={onMenuKeyDown}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.97 }}
transition={{ duration: reduce ? 0.12 : 0.16, ease: [0.22, 1, 0.36, 1] }}
className="absolute bottom-full left-0 z-20 mb-2 flex gap-0.5 rounded-2xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
>
{REACTIONS.map((reaction, index) => {
const picked = mine.includes(reaction.key);
return (
<button
key={reaction.key}
ref={(node) => {
optionRefs.current[index] = node;
}}
type="button"
role="menuitemcheckbox"
aria-checked={picked}
tabIndex={index === active ? 0 : -1}
onFocus={() => setActive(index)}
onClick={() => {
onPick(reaction.key);
close(true);
}}
title={reaction.label}
className="cmtrx-pop relative grid h-9 w-9 place-items-center rounded-xl text-lg transition-transform hover:scale-110 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800"
>
<span aria-hidden="true">{reaction.glyph}</span>
<span className="sr-only">{reaction.label}</span>
{picked ? (
<span className="absolute bottom-1 h-1 w-1 rounded-full bg-indigo-500 dark:bg-indigo-400" />
) : null}
</button>
);
})}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
type ChipProps = {
reaction: Reaction;
count: number;
active: boolean;
onToggle: () => void;
};
function ReactionChip({ reaction, count, active, onToggle }: ChipProps) {
const reduce = useReducedMotion();
return (
<button
type="button"
aria-pressed={active}
onClick={onToggle}
className={[
"group inline-flex h-8 select-none items-center gap-1.5 rounded-full border px-2.5 text-sm font-medium tabular-nums transition-colors",
"focus-visible: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-slate-950",
active
? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/50 dark:bg-indigo-500/15 dark:text-indigo-200"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800",
].join(" ")}
>
<motion.span
aria-hidden="true"
key={`${reaction.key}-${active ? "on" : "off"}`}
initial={reduce ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 520, damping: 18 }}
className="text-base leading-none"
>
{reaction.glyph}
</motion.span>
<span>{count}</span>
<span className="sr-only">
{reaction.label} — {active ? "you reacted, activate to remove" : "activate to react"}
</span>
</button>
);
}
export default function CommentReactions() {
const [comments, setComments] = useState<Comment[]>(() => SEED.map((c) => ({ ...c })));
const [draft, setDraft] = useState("");
const [status, setStatus] = useState("");
const reduce = useReducedMotion();
const draftId = useId();
const toggle = useCallback((commentId: string, key: ReactionKey) => {
setComments((prev) =>
prev.map((comment) => {
if (comment.id !== commentId) return comment;
const had = comment.mine.includes(key);
const current = comment.counts[key] ?? 0;
const nextCount = had ? current - 1 : current + 1;
const counts: Partial<Record<ReactionKey, number>> = { ...comment.counts };
if (nextCount <= 0) delete counts[key];
else counts[key] = nextCount;
return {
...comment,
counts,
mine: had ? comment.mine.filter((k) => k !== key) : [...comment.mine, key],
};
}),
);
setStatus(
`${REACTION_BY_KEY[key].label} reaction updated`,
);
}, []);
const total = useMemo(
() =>
comments.reduce(
(sum, comment) =>
sum + Object.values(comment.counts).reduce((a: number, b) => a + (b ?? 0), 0),
0,
),
[comments],
);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = draft.trim();
if (!text) {
setStatus("Write something before posting.");
return;
}
setComments((prev) => [
...prev,
{
id: `c${prev.length + 1}-${Date.now()}`,
author: "You",
initials: "YO",
role: "Reader",
posted: "just now",
body: text,
accent: "violet",
counts: {},
mine: [],
},
]);
setDraft("");
setStatus("Comment posted.");
};
return (
<section className="relative w-full bg-white px-5 py-20 sm:px-8 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes cmtrx-rise {
0% { transform: translateY(0) scale(0.85); opacity: 0; }
30% { opacity: 1; }
100% { transform: translateY(-14px) scale(1.05); opacity: 0; }
}
.cmtrx-pop { animation: none; }
@media (prefers-reduced-motion: no-preference) {
.cmtrx-pop:hover span[aria-hidden="true"] {
animation: cmtrx-rise 0.9s ease-out infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.cmtrx-pop, .cmtrx-pop:hover span[aria-hidden="true"] { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-6 dark:border-slate-800">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
Discussion
</h2>
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
{comments.length} comments · {total} reactions · thread closes in 3 days
</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
Live
</span>
</header>
<ul className="space-y-3">
<AnimatePresence initial={false}>
{comments.map((comment) => {
const keys = REACTIONS.filter((r) => (comment.counts[r.key] ?? 0) > 0);
return (
<motion.li
key={comment.id}
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
className="rounded-2xl border border-slate-200 bg-white p-5 transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/40 dark:hover:border-slate-700"
>
<div className="flex items-start gap-3.5">
<span
aria-hidden="true"
className={`grid h-10 w-10 shrink-0 place-items-center rounded-full text-xs font-semibold ${
ACCENT_RING[comment.accent] ?? ACCENT_RING.indigo
}`}
>
{comment.initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{comment.author}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{comment.role}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
· {comment.posted}
</span>
</div>
<p className="mt-2 text-[0.9375rem] leading-relaxed text-slate-700 dark:text-slate-300">
{comment.body}
</p>
<div className="mt-4 flex flex-wrap items-center gap-1.5">
{keys.map((reaction) => (
<ReactionChip
key={reaction.key}
reaction={reaction}
count={comment.counts[reaction.key] ?? 0}
active={comment.mine.includes(reaction.key)}
onToggle={() => toggle(comment.id, reaction.key)}
/>
))}
<ReactionPicker
commentId={comment.id}
mine={comment.mine}
onPick={(key) => toggle(comment.id, key)}
/>
</div>
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<form
onSubmit={submit}
className="mt-6 rounded-2xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
>
<label
htmlFor={draftId}
className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Add to the thread
</label>
<textarea
id={draftId}
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
maxLength={600}
placeholder="What did this miss? Concrete numbers beat opinions."
className="mt-2 w-full resize-y rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-800 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
{draft.length}/600
</span>
<button
type="submit"
className="inline-flex h-9 items-center rounded-full bg-slate-900 px-4 text-sm font-medium text-white transition-colors hover:bg-slate-700 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:opacity-40 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
disabled={draft.trim().length === 0}
>
Post comment
</button>
</div>
</form>
<p aria-live="polite" className="sr-only">
{status}
</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 →
Comments Thread
Originalcomment thread with votes

Comments Nested
Originalnested reply comments

Comments Form
Originalcomment composer with avatar

Comments Reviews
Originalproduct review comments with stars

Comments Chat Style
Originalchat-style comment section

Comments Moderation
Originalcomments with moderation actions

Comments Live
Originallive comment stream

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

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

