Web InnoventixFreeCode

Comments Form

Original · free

comment composer with avatar

byWeb InnoventixReact + Tailwind
commentformcomments
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/comment-form.json
comment-form.tsx
"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

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 →