Comment Form
Original ยท freecomment box with avatar and actions
byWeb InnoventixReact + Tailwind
formcommentforms
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/form-comment.jsonform-comment.tsx
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CommentItem = {
id: string;
author: string;
handle: string;
initials: string;
tint: string;
time: string;
body: string;
likes: number;
liked: boolean;
};
const MAX = 500;
const TINTS = [
"from-indigo-500 to-violet-500",
"from-emerald-500 to-sky-500",
"from-rose-500 to-amber-500",
"from-sky-500 to-indigo-500",
"from-violet-500 to-rose-500",
];
const EMOJI = ["๐", "โค๏ธ", "๐", "๐", "๐", "๐", "๐ฅ", "๐ก", "๐", "โ
", "๐ค", "โ"];
const SEED: CommentItem[] = [
{
id: "c-priya",
author: "Priya Nair",
handle: "priya.builds",
initials: "PN",
tint: TINTS[0],
time: "3h ago",
body: "This matches what we saw moving to a remote build cache โ restoring it *before* the install step was the single biggest win. One gotcha: if CI installs first, you pay for the cache twice and it looks like it did nothing.",
likes: 24,
liked: false,
},
{
id: "c-marcus",
author: "Marcus Brandt",
handle: "mbrandt",
initials: "MB",
tint: TINTS[1],
time: "5h ago",
body: "Solid write-up. How are you handling flaky tests in the parallel lanes? We quarantined ours into a separate job so a single flake can't block a deploy, and it changed how the team trusts green.",
likes: 11,
liked: false,
},
{
id: "c-lena",
author: "Lena Ortiz",
handle: "lena.o",
initials: "LO",
tint: TINTS[2],
time: "1d ago",
body: "Bookmarking this. Incremental type-checking alone gave us back about 40 minutes a day across the frontend team โ genuinely wild how much dead time was hiding in cold checks.",
likes: 7,
liked: false,
},
];
function Avatar({
initials,
tint,
size,
}: {
initials: string;
tint: string;
size: "sm" | "md";
}) {
const box = size === "md" ? "h-10 w-10 text-sm" : "h-9 w-9 text-xs";
return (
<span
aria-hidden="true"
className={`inline-flex ${box} shrink-0 select-none items-center justify-center rounded-full bg-gradient-to-br ${tint} font-semibold tracking-wide text-white shadow-sm ring-1 ring-black/5 dark:ring-white/10`}
>
{initials}
</span>
);
}
export default function FormComment() {
const reduce = useReducedMotion();
const uid = useId();
const taRef = useRef<HTMLTextAreaElement>(null);
const emojiWrapRef = useRef<HTMLDivElement>(null);
const pendingSel = useRef<[number, number] | null>(null);
const counter = useRef(0);
const [value, setValue] = useState("");
const [comments, setComments] = useState<CommentItem[]>(SEED);
const [emojiOpen, setEmojiOpen] = useState(false);
const len = value.length;
const over = len > MAX;
const trimmedEmpty = value.trim().length === 0;
const remaining = MAX - len;
const progress = Math.min(len / MAX, 1);
const R = 9;
const C = 2 * Math.PI * R;
const ringColor = over
? "stroke-rose-500"
: progress > 0.8
? "stroke-amber-500"
: "stroke-indigo-500";
useLayoutEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 260) + "px";
if (pendingSel.current) {
const [s, e] = pendingSel.current;
ta.focus();
ta.setSelectionRange(s, e);
pendingSel.current = null;
}
}, [value]);
useEffect(() => {
if (!emojiOpen) return;
function onDown(ev: globalThis.MouseEvent) {
if (
emojiWrapRef.current &&
!emojiWrapRef.current.contains(ev.target as Node)
) {
setEmojiOpen(false);
}
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [emojiOpen]);
function wrap(before: string, after: string, placeholder: string) {
const ta = taRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = value.slice(start, end) || placeholder;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
const selStart = start + before.length;
pendingSel.current = [selStart, selStart + selected.length];
setValue(next);
}
function insertAtCursor(text: string) {
const ta = taRef.current;
const start = ta ? ta.selectionStart : value.length;
const end = ta ? ta.selectionEnd : value.length;
const next = value.slice(0, start) + text + value.slice(end);
const caret = start + text.length;
pendingSel.current = [caret, caret];
setValue(next);
}
function post() {
if (trimmedEmpty || over) return;
counter.current += 1;
const item: CommentItem = {
id: `new-${counter.current}`,
author: "You",
handle: "you",
initials: "YO",
tint: TINTS[0],
time: "Just now",
body: value.trim(),
likes: 0,
liked: false,
};
setComments((prev) => [item, ...prev]);
setValue("");
setEmojiOpen(false);
}
function toggleLike(id: string) {
setComments((prev) =>
prev.map((c) =>
c.id === id
? { ...c, liked: !c.liked, likes: c.likes + (c.liked ? -1 : 1) }
: c,
),
);
}
function replyTo(author: string) {
const mention = `@${author.split(" ")[0]} `;
setValue((v) => (v.startsWith(mention) ? v : mention + v));
pendingSel.current = null;
requestAnimationFrame(() => {
const ta = taRef.current;
if (ta) {
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}
});
}
const toolBtn =
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 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-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-950";
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes fc-cmt-pop {
0% { transform: scale(1); }
40% { transform: scale(1.34); }
100% { transform: scale(1); }
}
@keyframes fc-cmt-caret {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.fc-cmt-anim { animation-duration: .36s; animation-timing-function: cubic-bezier(.34,1.56,.64,1); }
@media (prefers-reduced-motion: reduce) {
.fc-cmt-anim { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-28 left-1/2 h-72 w-[36rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/30 via-violet-300/25 to-sky-300/30 blur-3xl dark:from-indigo-600/15 dark:via-violet-600/15 dark:to-sky-600/15"
/>
<div className="relative mx-auto max-w-2xl">
<header className="mb-6 flex items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Discussion
</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
Join the thread
</h2>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm ring-1 ring-slate-200 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-white/10">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{comments.length}
</span>
</header>
{/* Composer */}
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/[0.03] transition-shadow focus-within:border-indigo-300 focus-within:shadow-md focus-within:shadow-indigo-500/10 sm:p-5 dark:border-white/10 dark:bg-zinc-900 dark:focus-within:border-indigo-500/40">
<div className="flex gap-3 sm:gap-4">
<Avatar initials="YO" tint={TINTS[0]} size="md" />
<div className="min-w-0 flex-1">
<label htmlFor={`${uid}-ta`} className="sr-only">
Write a comment
</label>
<textarea
id={`${uid}-ta`}
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
post();
}
}}
rows={2}
placeholder="Add to the discussion โ what worked for your team?"
aria-describedby={`${uid}-count ${uid}-hint`}
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-relaxed text-slate-800 outline-none placeholder:text-slate-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-white/5">
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => wrap("**", "**", "bold")}
className={toolBtn}
aria-label="Bold"
title="Bold"
>
<span className="text-sm font-bold">B</span>
</button>
<button
type="button"
onClick={() => wrap("_", "_", "italic")}
className={toolBtn}
aria-label="Italic"
title="Italic"
>
<span className="text-sm font-semibold italic">I</span>
</button>
<button
type="button"
onClick={() => wrap("`", "`", "code")}
className={toolBtn}
aria-label="Inline code"
title="Inline code"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="m8 8-4 4 4 4m8-8 4 4-4 4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => wrap("[", "](https://)", "link text")}
className={toolBtn}
aria-label="Insert link"
title="Insert link"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1m-1 8a5 5 0 0 1-7 0 5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 0"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<span className="mx-1 h-5 w-px bg-slate-200 dark:bg-white/10" />
<div ref={emojiWrapRef} className="relative">
<button
type="button"
onClick={() => setEmojiOpen((o) => !o)}
className={toolBtn}
aria-label="Add emoji"
title="Add emoji"
aria-haspopup="menu"
aria-expanded={emojiOpen}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.7" />
<path
d="M8.5 14.5a4 4 0 0 0 7 0"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<path d="M9 9.5h.01M15 9.5h.01" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence>
{emojiOpen && (
<motion.div
role="menu"
aria-label="Emoji"
initial={reduce ? false : { opacity: 0, y: 6, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.96 }}
transition={{ duration: 0.16, ease: "easeOut" }}
onKeyDown={(e) => {
if (e.key === "Escape") {
setEmojiOpen(false);
taRef.current?.focus();
}
}}
className="absolute bottom-10 left-0 z-10 grid w-52 grid-cols-6 gap-1 rounded-xl border border-slate-200 bg-white p-2 shadow-xl shadow-slate-900/10 dark:border-white/10 dark:bg-zinc-800"
>
{EMOJI.map((em) => (
<button
key={em}
type="button"
role="menuitem"
onClick={() => {
insertAtCursor(em);
setEmojiOpen(false);
}}
aria-label={`Insert ${em}`}
className="flex h-8 w-8 items-center justify-center rounded-lg text-lg transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-white/10"
>
{em}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex items-center gap-3">
<div
className="flex items-center gap-1.5"
aria-hidden={remaining > 60}
>
{remaining <= 60 && (
<span
className={`text-xs tabular-nums ${over ? "font-semibold text-rose-500" : "text-slate-400 dark:text-zinc-500"}`}
>
{remaining}
</span>
)}
<svg viewBox="0 0 24 24" className="h-6 w-6 -rotate-90" aria-hidden="true">
<circle cx="12" cy="12" r={R} className="stroke-slate-200 dark:stroke-white/10" strokeWidth="2.5" fill="none" />
<circle
cx="12"
cy="12"
r={R}
className={ringColor}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C * (1 - progress)}
style={{ transition: reduce ? undefined : "stroke-dashoffset .2s ease" }}
/>
</svg>
</div>
<button
type="button"
onClick={post}
disabled={trimmedEmpty || over}
className="inline-flex items-center gap-1.5 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm 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-white disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:focus-visible:ring-offset-zinc-900 dark:disabled:bg-white/10 dark:disabled:text-zinc-500"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M4 12 20 4l-3.5 16-4-6.5L4 12Z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
Post
</button>
</div>
</div>
<p
id={`${uid}-hint`}
className="mt-2 text-[11px] text-slate-400 dark:text-zinc-500"
>
<span aria-hidden="true">**bold** ยท _italic_ ยท `code` supported โ </span>
press{" "}
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-sans text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/5 dark:text-zinc-400">
Ctrl
</kbd>{" "}
+{" "}
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-sans text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/5 dark:text-zinc-400">
Enter
</kbd>{" "}
to post
</p>
<span id={`${uid}-count`} className="sr-only" aria-live="polite">
{over
? `${len - MAX} characters over the limit`
: `${remaining} characters remaining`}
</span>
</div>
</div>
</div>
{/* Thread */}
<ul className="mt-6 space-y-3" aria-label="Comments">
<AnimatePresence initial={false}>
{comments.map((c) => (
<motion.li
key={c.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/[0.02] sm:p-5 dark:border-white/10 dark:bg-zinc-900"
>
<div className="flex gap-3 sm:gap-4">
<Avatar initials={c.initials} tint={c.tint} size="sm" />
<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-white">
{c.author}
</span>
<span className="text-xs text-slate-400 dark:text-zinc-500">
@{c.handle}
</span>
<span aria-hidden="true" className="text-slate-300 dark:text-zinc-600">ยท</span>
<span className="text-xs text-slate-400 dark:text-zinc-500">
{c.time}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap break-words text-[15px] leading-relaxed text-slate-700 dark:text-zinc-300">
{c.body}
</p>
<div className="mt-3 flex items-center gap-1">
<button
type="button"
onClick={() => toggleLike(c.id)}
aria-pressed={c.liked}
aria-label={`Like comment from ${c.author}`}
className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
c.liked
? "text-rose-600 dark:text-rose-400"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-800 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100"
}`}
>
<svg
viewBox="0 0 24 24"
className="fc-cmt-anim h-4 w-4"
fill={c.liked ? "currentColor" : "none"}
aria-hidden="true"
style={{ animationName: c.liked ? "fc-cmt-pop" : undefined }}
>
<path
d="M12 20.5S3.5 15 3.5 8.9A4.4 4.4 0 0 1 12 6.8a4.4 4.4 0 0 1 8.5 2.1c0 6.1-8.5 11.6-8.5 11.6Z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="tabular-nums">{c.likes}</span>
</button>
<button
type="button"
onClick={() => replyTo(c.author)}
aria-label={`Reply to ${c.author}`}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 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-zinc-400 dark:hover:bg-white/5 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M9 15 4 10l5-5m-4.5 5H14a6 6 0 0 1 6 6v3"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Reply
</button>
</div>
</div>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
</section>
);
}Dependencies
motion
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 โ
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

