Web InnoventixFreeCode

Comment Form

Original ยท free

comment box with avatar and actions

byWeb InnoventixReact + Tailwind
formcommentforms
Open

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.json
form-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 quote

Similar components

Browse all โ†’