Comments Form
Original · freecomment composer with avatar
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-form.json"use client";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToneKey = "neutral" | "asking" | "shipping";
type Tone = {
key: ToneKey;
label: string;
hint: string;
};
type Attachment = {
id: string;
name: string;
size: string;
};
type PostedComment = {
id: string;
body: string;
tone: ToneKey;
attachments: Attachment[];
postedAt: number;
};
const TONES: Tone[] = [
{ key: "neutral", label: "Comment", hint: "A plain reply on the thread." },
{ key: "asking", label: "Question", hint: "Flags the thread as awaiting an answer." },
{ key: "shipping", label: "Changelog", hint: "Pins your note to the release notes." },
];
const MAX_CHARS = 600;
const WARN_AT = 500;
const TONE_RING: Record<ToneKey, string> = {
neutral: "focus-visible:ring-indigo-500",
asking: "focus-visible:ring-amber-500",
shipping: "focus-visible:ring-emerald-500",
};
const TONE_CHIP: Record<ToneKey, string> = {
neutral:
"bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
asking:
"bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
shipping:
"bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
};
const SAMPLE_FILES: Array<{ name: string; size: string }> = [
{ name: "checkout-repro.mp4", size: "2.4 MB" },
{ name: "stack-trace.log", size: "18 KB" },
{ name: "lighthouse-before-after.png", size: "410 KB" },
];
function formatRelative(ms: number): string {
const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
}
function AvatarMark({ initials, ringed }: { initials: string; ringed: boolean }) {
return (
<span
className={[
"relative grid h-10 w-10 shrink-0 place-items-center rounded-full",
"bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500",
"text-sm font-semibold tracking-tight text-white",
"shadow-sm ring-2 transition-colors duration-300",
ringed
? "ring-indigo-400/70 dark:ring-indigo-400/60"
: "ring-white dark:ring-slate-900",
].join(" ")}
aria-hidden="true"
>
{initials}
<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900" />
</span>
);
}
export default function CommentForm() {
const reduceMotion = useReducedMotion();
const uid = useId();
const textareaId = `${uid}-body`;
const counterId = `${uid}-counter`;
const toneHintId = `${uid}-tone-hint`;
const errorId = `${uid}-error`;
const [body, setBody] = useState<string>("");
const [tone, setTone] = useState<ToneKey>("neutral");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [focused, setFocused] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [posted, setPosted] = useState<PostedComment[]>([]);
const [, setTick] = useState<number>(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const toneRefs = useRef<Array<HTMLButtonElement | null>>([]);
const remaining = MAX_CHARS - body.length;
const overLimit = remaining < 0;
const trimmed = body.trim();
const canPost = trimmed.length > 0 && !overLimit;
const activeTone = useMemo(
() => TONES.find((t) => t.key === tone) ?? TONES[0],
[tone],
);
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
}, [body]);
useEffect(() => {
if (posted.length === 0) return;
const id = window.setInterval(() => setTick((n) => n + 1), 15000);
return () => window.clearInterval(id);
}, [posted.length]);
const submit = useCallback(() => {
if (!canPost) {
setError(
overLimit
? `Trim ${Math.abs(remaining)} character${Math.abs(remaining) === 1 ? "" : "s"} before posting.`
: "Write something before posting.",
);
textareaRef.current?.focus();
return;
}
const entry: PostedComment = {
id: `${Date.now()}-${posted.length}`,
body: trimmed,
tone,
attachments,
postedAt: Date.now(),
};
setPosted((prev) => [entry, ...prev].slice(0, 4));
setBody("");
setAttachments([]);
setError("");
setStatus(`Posted as ${activeTone.label.toLowerCase()}.`);
textareaRef.current?.focus();
}, [
canPost,
overLimit,
remaining,
trimmed,
tone,
attachments,
posted.length,
activeTone.label,
]);
const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
submit();
}
};
const onToneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const index = TONES.findIndex((t) => t.key === tone);
let next = -1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % TONES.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + TONES.length) % TONES.length;
if (event.key === "Home") next = 0;
if (event.key === "End") next = TONES.length - 1;
if (next < 0) return;
event.preventDefault();
setTone(TONES[next].key);
toneRefs.current[next]?.focus();
};
const addAttachment = () => {
const pick = SAMPLE_FILES[attachments.length % SAMPLE_FILES.length];
if (attachments.length >= 3) {
setStatus("Attachment limit reached — 3 files per comment.");
return;
}
const file: Attachment = {
id: `${pick.name}-${Date.now()}`,
name: pick.name,
size: pick.size,
};
setAttachments((prev) => [...prev, file]);
setStatus(`Attached ${pick.name}.`);
};
const removeAttachment = (id: string, name: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
setStatus(`Removed ${name}.`);
};
const counterTone = overLimit
? "text-rose-600 dark:text-rose-400"
: body.length >= WARN_AT
? "text-amber-600 dark:text-amber-400"
: "text-slate-400 dark:text-slate-500";
return (
<section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes cmpsr-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes cmpsr-caret {
0%, 45% { opacity: 1; }
50%, 95% { opacity: 0.25; }
100% { opacity: 1; }
}
.cmpsr-rise { animation: cmpsr-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.cmpsr-caret { animation: cmpsr-caret 1.6s steps(1, end) infinite; }
@media (prefers-reduced-motion: reduce) {
.cmpsr-rise, .cmpsr-caret { 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">
Thread · PR #418
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Cut checkout latency to under 400ms
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Six people are watching this thread. Be specific — link the trace, name the file.
</p>
</header>
<div
className={[
"cmpsr-rise rounded-2xl border bg-white shadow-sm transition-shadow duration-300",
"dark:bg-slate-900",
focused
? "border-indigo-300 shadow-md shadow-indigo-500/5 dark:border-indigo-500/50"
: "border-slate-200 dark:border-slate-800",
].join(" ")}
>
<div className="flex gap-3 p-4 sm:gap-4 sm:p-5">
<div className="pt-1">
<AvatarMark initials="SR" ringed={focused} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Salman Raza
</span>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${TONE_CHIP[tone]}`}
>
{activeTone.label}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
posting to <span className="font-medium text-slate-500 dark:text-slate-400">#perf-squad</span>
</span>
</div>
<label htmlFor={textareaId} className="sr-only">
Write a comment
</label>
<textarea
id={textareaId}
ref={textareaRef}
value={body}
rows={3}
onChange={(e) => {
setBody(e.target.value);
if (error) setError("");
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={onKeyDown}
placeholder="The p95 spike starts after the coupon revalidation call — trace attached."
aria-describedby={`${counterId} ${toneHintId}${error ? ` ${errorId}` : ""}`}
aria-invalid={overLimit || error.length > 0}
className={[
"mt-2 block w-full resize-none rounded-lg bg-transparent px-0 py-1 text-[15px] leading-relaxed",
"text-slate-800 placeholder:text-slate-400 dark:text-slate-200 dark:placeholder:text-slate-500",
"outline-none focus-visible:outline-none",
].join(" ")}
/>
<AnimatePresence initial={false}>
{attachments.length > 0 && (
<motion.ul
initial={reduceMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] }}
className="mt-3 space-y-1.5 overflow-hidden"
>
{attachments.map((file) => (
<li
key={file.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 dark:border-slate-800 dark:bg-slate-800/50"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500"
aria-hidden="true"
>
<path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700 dark:text-slate-300">
{file.name}
</span>
<span className="shrink-0 text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{file.size}
</span>
<button
type="button"
onClick={() => removeAttachment(file.id, file.name)}
className="shrink-0 rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">Remove {file.name}</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
<div
role="radiogroup"
aria-label="Comment type"
onKeyDown={onToneKeyDown}
className="mt-4 inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 dark:border-slate-800 dark:bg-slate-800/60"
>
{TONES.map((t, i) => {
const selected = t.key === tone;
return (
<button
key={t.key}
ref={(el) => {
toneRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setTone(t.key)}
className={[
"relative rounded-[7px] px-3 py-1.5 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1",
"focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900",
TONE_RING[t.key],
selected
? "text-slate-900 dark:text-slate-50"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
{selected && (
<motion.span
layoutId={reduceMotion ? undefined : `${uid}-tone-pill`}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-0 rounded-[7px] bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-700 dark:ring-slate-600"
/>
)}
<span className="relative">{t.label}</span>
</button>
);
})}
</div>
<p id={toneHintId} className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{activeTone.hint}
</p>
{error && (
<p
id={errorId}
className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400"
>
{error}
</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 sm:px-5 dark:border-slate-800">
<div className="flex items-center gap-1">
<button
type="button"
onClick={addAttachment}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
Attach
</button>
<button
type="button"
onClick={() => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.slice(start, end);
const next = `${body.slice(0, start)}\`${selected || "code"}\`${body.slice(end)}`;
setBody(next);
window.requestAnimationFrame(() => {
el.focus();
const caret = start + 1 + (selected || "code").length;
el.setSelectionRange(selected ? caret + 1 : start + 1, selected ? caret + 1 : caret);
});
}}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
</svg>
Code
</button>
</div>
<div className="flex items-center gap-3">
<span
id={counterId}
aria-live="polite"
className={`text-xs tabular-nums transition-colors ${counterTone}`}
>
{overLimit ? `${Math.abs(remaining)} over` : `${remaining} left`}
</span>
<button
type="button"
onClick={submit}
disabled={!canPost}
className={[
"inline-flex items-center gap-2 rounded-lg px-3.5 py-2 text-xs font-semibold transition-all",
"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-900",
canPost
? "bg-slate-900 text-white shadow-sm hover:bg-slate-700 active:scale-[0.98] dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white"
: "cursor-not-allowed bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600",
].join(" ")}
>
Post
<kbd className="rounded border border-white/25 px-1 py-px font-sans text-[10px] font-medium opacity-70 dark:border-slate-900/20">
⌘↵
</kbd>
</button>
</div>
</div>
</div>
<p aria-live="polite" className="sr-only">
{status}
</p>
{posted.length > 0 && (
<ul className="mt-8 space-y-3">
<AnimatePresence initial={false}>
{posted.map((item) => {
const meta = TONES.find((t) => t.key === item.tone) ?? TONES[0];
return (
<motion.li
key={item.id}
initial={reduceMotion ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
className="flex gap-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900/60"
>
<AvatarMark initials="SR" ringed={false} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Salman Raza
</span>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${TONE_CHIP[item.tone]}`}
>
{meta.label}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{formatRelative(item.postedAt)}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-700 dark:text-slate-300">
{item.body}
</p>
{item.attachments.length > 0 && (
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
{item.attachments.map((a) => a.name).join(" · ")}
</p>
)}
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
{posted.length === 0 && (
<p className="mt-8 flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
No replies yet — yours starts the thread
<span className="cmpsr-caret inline-block h-3 w-px bg-slate-400 dark:bg-slate-500" />
</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 Reactions
Originalcomments with emoji reactions

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.

