Web InnoventixFreeCode

Comments Nested

Original · free

nested reply comments

byWeb InnoventixReact + Tailwind
commentnestedcomments
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-nested.json
comment-nested.tsx
"use client";

import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Vote = 1 | 0 | -1;

type CommentNode = {
  id: string;
  author: string;
  handle: string;
  initials: string;
  role?: string;
  postedAt: string;
  body: string;
  score: number;
  vote: Vote;
  collapsed: boolean;
  children: CommentNode[];
};

const SEED: CommentNode[] = [
  {
    id: "c1",
    author: "Priya Raghavan",
    handle: "praghavan",
    initials: "PR",
    role: "Maintainer",
    postedAt: "4 hours ago",
    body: "We moved the parser off regex and onto a hand-written tokenizer last sprint. Cold-start dropped from 480ms to 91ms on the 12k-line fixture. The tradeoff: the tokenizer is 700 lines and needs real tests, not snapshots.",
    score: 148,
    vote: 0,
    collapsed: false,
    children: [
      {
        id: "c1-1",
        author: "Dean Whitlock",
        handle: "dwhit",
        initials: "DW",
        postedAt: "3 hours ago",
        body: "91ms is the median or p95? Median numbers hide the pathological inputs, and the pathological inputs are exactly what killed the regex version.",
        score: 62,
        vote: 0,
        collapsed: false,
        children: [
          {
            id: "c1-1-1",
            author: "Priya Raghavan",
            handle: "praghavan",
            initials: "PR",
            role: "Maintainer",
            postedAt: "3 hours ago",
            body: "p95, measured across 200 runs on a cold Node 22 process. Median is 74ms. The worst case we could construct — deeply nested template literals — lands at 130ms, versus 4.2s before.",
            score: 91,
            vote: 1,
            collapsed: false,
            children: [
              {
                id: "c1-1-1-1",
                author: "Dean Whitlock",
                handle: "dwhit",
                initials: "DW",
                postedAt: "2 hours ago",
                body: "That answers it. Retracting the concern.",
                score: 24,
                vote: 0,
                collapsed: false,
                children: [],
              },
            ],
          },
          {
            id: "c1-1-2",
            author: "Marta Kovács",
            handle: "mkovacs",
            initials: "MK",
            postedAt: "2 hours ago",
            body: "Worth publishing the benchmark harness alongside the PR. Half the arguments in this thread are people benchmarking different things and assuming the other person is wrong.",
            score: 40,
            vote: 0,
            collapsed: false,
            children: [],
          },
        ],
      },
      {
        id: "c1-2",
        author: "Tomás Álvarez",
        handle: "talvarez",
        initials: "TA",
        postedAt: "2 hours ago",
        body: "700 lines is not a lot for a tokenizer. The Go standard library's scanner is roughly 900 and nobody calls it unmaintainable. Line count is a bad proxy for the thing you're actually worried about.",
        score: 33,
        vote: -1,
        collapsed: false,
        children: [],
      },
    ],
  },
  {
    id: "c2",
    author: "Iris Ferreira",
    handle: "iferreira",
    initials: "IF",
    postedAt: "1 hour ago",
    body: "Small dissent: the docs still describe the old regex behaviour for escaped delimiters. Anyone upgrading is going to hit that and file it as a bug. Ship the doc change in the same release, not the one after.",
    score: 57,
    vote: 0,
    collapsed: false,
    children: [
      {
        id: "c2-1",
        author: "Priya Raghavan",
        handle: "praghavan",
        initials: "PR",
        role: "Maintainer",
        postedAt: "52 minutes ago",
        body: "Fair. Opened #4471 to track it and marked it a release blocker.",
        score: 29,
        vote: 0,
        collapsed: false,
        children: [],
      },
    ],
  },
];

function countNodes(nodes: CommentNode[]): number {
  return nodes.reduce((sum, node) => sum + 1 + countNodes(node.children), 0);
}

function mapTree(
  nodes: CommentNode[],
  id: string,
  fn: (node: CommentNode) => CommentNode
): CommentNode[] {
  return nodes.map((node) => {
    if (node.id === id) return fn(node);
    if (node.children.length === 0) return node;
    return { ...node, children: mapTree(node.children, id, fn) };
  });
}

const ArrowIcon = ({ up }: { up: boolean }) => (
  <svg
    viewBox="0 0 16 16"
    aria-hidden="true"
    className={`h-3.5 w-3.5 ${up ? "" : "rotate-180"}`}
    fill="none"
    stroke="currentColor"
    strokeWidth="1.9"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />
  </svg>
);

const ReplyIcon = () => (
  <svg
    viewBox="0 0 16 16"
    aria-hidden="true"
    className="h-3.5 w-3.5"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.6"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="M7 3.5 2.5 8 7 12.5M2.5 8h6.75A4.25 4.25 0 0 1 13.5 12.25v.75" />
  </svg>
);

const ChevronIcon = ({ collapsed }: { collapsed: boolean }) => (
  <svg
    viewBox="0 0 16 16"
    aria-hidden="true"
    className={`h-3 w-3 transition-transform duration-200 ${
      collapsed ? "-rotate-90" : ""
    }`}
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="m3.5 6 4.5 4.5L12.5 6" />
  </svg>
);

const ring =
  "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";

type CommentProps = {
  node: CommentNode;
  depth: number;
  replyingTo: string | null;
  onVote: (id: string, dir: 1 | -1) => void;
  onToggle: (id: string) => void;
  onReplyOpen: (id: string | null) => void;
  onReplySubmit: (id: string, body: string) => void;
  reduced: boolean;
};

function Comment({
  node,
  depth,
  replyingTo,
  onVote,
  onToggle,
  onReplyOpen,
  onReplySubmit,
  reduced,
}: CommentProps) {
  const bodyId = useId();
  const [draft, setDraft] = useState("");
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const isReplying = replyingTo === node.id;
  const hidden = countNodes(node.children);

  const submit = () => {
    const text = draft.trim();
    if (!text) return;
    onReplySubmit(node.id, text);
    setDraft("");
  };

  const accent =
    node.vote === 1
      ? "text-emerald-600 dark:text-emerald-400"
      : node.vote === -1
        ? "text-rose-600 dark:text-rose-400"
        : "text-slate-500 dark:text-slate-400";

  return (
    <li className="relative">
      <div className="flex gap-3">
        <div className="flex w-9 shrink-0 flex-col items-center gap-2">
          <span
            aria-hidden="true"
            className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-[11px] font-semibold tracking-wide text-white shadow-sm"
          >
            {node.initials}
          </span>
          {!node.collapsed && (
            <button
              type="button"
              onClick={() => onToggle(node.id)}
              aria-label={`Collapse thread from ${node.author}`}
              className={`cn-rail group relative w-px flex-1 cursor-pointer rounded-full border-0 bg-slate-200 p-0 transition-colors hover:bg-indigo-400 dark:bg-slate-800 dark:hover:bg-indigo-500 ${ring} focus-visible:ring-offset-0`}
            >
              <span className="absolute -inset-x-2 inset-y-0" />
            </button>
          )}
        </div>

        <div className="min-w-0 flex-1 pb-5">
          <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] leading-none">
            <button
              type="button"
              onClick={() => onToggle(node.id)}
              aria-expanded={!node.collapsed}
              aria-controls={bodyId}
              className={`-m-1 grid h-6 w-6 place-items-center rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 ${ring}`}
            >
              <span className="sr-only">
                {node.collapsed ? "Expand" : "Collapse"} comment by {node.author}
              </span>
              <ChevronIcon collapsed={node.collapsed} />
            </button>
            <span className="font-semibold text-slate-900 dark:text-slate-100">
              {node.author}
            </span>
            {node.role && (
              <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
                {node.role}
              </span>
            )}
            <span className="text-slate-400 dark:text-slate-500">
              @{node.handle}
            </span>
            <span aria-hidden="true" className="text-slate-300 dark:text-slate-700">
              ·
            </span>
            <span className="text-slate-400 dark:text-slate-500">
              {node.postedAt}
            </span>
            {node.collapsed && hidden > 0 && (
              <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                +{hidden} {hidden === 1 ? "reply" : "replies"}
              </span>
            )}
          </div>

          <div id={bodyId} hidden={node.collapsed}>
            <p className="mt-2 text-[14.5px] leading-relaxed text-slate-600 dark:text-slate-300">
              {node.body}
            </p>

            <div className="mt-3 flex flex-wrap items-center gap-1.5">
              <div className="inline-flex items-center gap-0.5 rounded-full border border-slate-200 bg-white p-0.5 dark:border-slate-800 dark:bg-slate-900">
                <button
                  type="button"
                  onClick={() => onVote(node.id, 1)}
                  aria-pressed={node.vote === 1}
                  aria-label={`Upvote comment by ${node.author}`}
                  className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${
                    node.vote === 1
                      ? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
                      : "text-slate-400 hover:bg-slate-100 hover:text-emerald-600 dark:hover:bg-slate-800 dark:hover:text-emerald-400"
                  } ${ring} focus-visible:ring-offset-0`}
                >
                  <ArrowIcon up />
                </button>
                <span
                  aria-live="polite"
                  className={`min-w-[2.25rem] text-center text-xs font-semibold tabular-nums ${accent}`}
                >
                  {node.score}
                </span>
                <button
                  type="button"
                  onClick={() => onVote(node.id, -1)}
                  aria-pressed={node.vote === -1}
                  aria-label={`Downvote comment by ${node.author}`}
                  className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${
                    node.vote === -1
                      ? "bg-rose-500/15 text-rose-600 dark:text-rose-400"
                      : "text-slate-400 hover:bg-slate-100 hover:text-rose-600 dark:hover:bg-slate-800 dark:hover:text-rose-400"
                  } ${ring} focus-visible:ring-offset-0`}
                >
                  <ArrowIcon up={false} />
                </button>
              </div>

              <button
                type="button"
                onClick={() => {
                  onReplyOpen(isReplying ? null : node.id);
                  if (!isReplying) {
                    window.requestAnimationFrame(() =>
                      textareaRef.current?.focus()
                    );
                  }
                }}
                aria-expanded={isReplying}
                className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
                  isReplying
                    ? "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400"
                    : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
                } ${ring}`}
              >
                <ReplyIcon />
                Reply
              </button>
            </div>

            <AnimatePresence initial={false}>
              {isReplying && (
                <motion.form
                  key="composer"
                  initial={reduced ? false : { opacity: 0, height: 0 }}
                  animate={{ opacity: 1, height: "auto" }}
                  exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
                  transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                  className="overflow-hidden"
                  onSubmit={(event) => {
                    event.preventDefault();
                    submit();
                  }}
                >
                  <div className="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-2 focus-within:border-indigo-400 dark:border-slate-800 dark:bg-slate-900/60 dark:focus-within:border-indigo-500">
                    <label htmlFor={`${bodyId}-reply`} className="sr-only">
                      Reply to {node.author}
                    </label>
                    <textarea
                      id={`${bodyId}-reply`}
                      ref={textareaRef}
                      rows={3}
                      value={draft}
                      onChange={(event) => setDraft(event.target.value)}
                      onKeyDown={(event) => {
                        if (event.key === "Escape") {
                          event.stopPropagation();
                          onReplyOpen(null);
                        }
                        if (
                          event.key === "Enter" &&
                          (event.metaKey || event.ctrlKey)
                        ) {
                          event.preventDefault();
                          submit();
                        }
                      }}
                      placeholder={`Reply to ${node.author.split(" ")[0]}…`}
                      className={`w-full resize-y rounded-lg bg-transparent px-2 py-1.5 text-sm text-slate-700 placeholder:text-slate-400 dark:text-slate-200 dark:placeholder:text-slate-600 ${ring} focus-visible:ring-offset-0`}
                    />
                    <div className="mt-1 flex items-center justify-between gap-3 px-1">
                      <span className="text-[11px] text-slate-400 dark:text-slate-600">
                        Ctrl + Enter to post · Esc to cancel
                      </span>
                      <div className="flex items-center gap-1.5">
                        <button
                          type="button"
                          onClick={() => onReplyOpen(null)}
                          className={`rounded-full px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${ring} focus-visible:ring-offset-0`}
                        >
                          Cancel
                        </button>
                        <button
                          type="submit"
                          disabled={draft.trim().length === 0}
                          className={`rounded-full bg-indigo-600 px-3.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-indigo-600 ${ring} focus-visible:ring-offset-0`}
                        >
                          Post reply
                        </button>
                      </div>
                    </div>
                  </div>
                </motion.form>
              )}
            </AnimatePresence>

            {node.children.length > 0 && (
              <ul className="mt-4 list-none space-y-0 p-0">
                {node.children.map((child) => (
                  <Comment
                    key={child.id}
                    node={child}
                    depth={depth + 1}
                    replyingTo={replyingTo}
                    onVote={onVote}
                    onToggle={onToggle}
                    onReplyOpen={onReplyOpen}
                    onReplySubmit={onReplySubmit}
                    reduced={reduced}
                  />
                ))}
              </ul>
            )}
          </div>
        </div>
      </div>
    </li>
  );
}

export default function CommentNested() {
  const [tree, setTree] = useState<CommentNode[]>(SEED);
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [newest, setNewest] = useState<string | null>(null);
  const seq = useRef(0);
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced === true;

  const total = useMemo(() => countNodes(tree), [tree]);

  const handleVote = useCallback((id: string, dir: 1 | -1) => {
    setTree((prev) =>
      mapTree(prev, id, (node) => {
        const next: Vote = node.vote === dir ? 0 : dir;
        return { ...node, vote: next, score: node.score - node.vote + next };
      })
    );
  }, []);

  const handleToggle = useCallback((id: string) => {
    setTree((prev) =>
      mapTree(prev, id, (node) => ({ ...node, collapsed: !node.collapsed }))
    );
  }, []);

  const handleReplySubmit = useCallback((id: string, body: string) => {
    seq.current += 1;
    const reply: CommentNode = {
      id: `local-${seq.current}`,
      author: "You",
      handle: "you",
      initials: "YO",
      postedAt: "just now",
      body,
      score: 1,
      vote: 1,
      collapsed: false,
      children: [],
    };
    setTree((prev) =>
      mapTree(prev, id, (node) => ({
        ...node,
        collapsed: false,
        children: [...node.children, reply],
      }))
    );
    setReplyingTo(null);
    setNewest(`Reply posted to the thread. ${body.slice(0, 60)}`);
  }, []);

  const collapseAll = (collapsed: boolean) => {
    const walk = (nodes: CommentNode[]): CommentNode[] =>
      nodes.map((node) => ({
        ...node,
        collapsed: node.children.length > 0 ? collapsed : false,
        children: walk(node.children),
      }));
    setTree((prev) => walk(prev));
  };

  return (
    <section className="relative w-full bg-white px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes cnNestedRise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: none; }
        }
        @keyframes cnNestedPulse {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 1; }
        }
        .cn-head { animation: cnNestedRise 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .cn-dot { animation: cnNestedPulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cn-head, .cn-dot { animation: none !important; }
          .cn-rail { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <header className="cn-head flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-5 dark:border-slate-800">
          <div>
            <div className="flex items-center gap-2">
              <span
                aria-hidden="true"
                className="cn-dot h-1.5 w-1.5 rounded-full bg-emerald-500"
              />
              <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
                Live discussion
              </span>
            </div>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
              RFC 0091 — Replace the regex parser
            </h2>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              <span className="font-medium text-slate-700 dark:text-slate-200 tabular-nums">
                {total}
              </span>{" "}
              comments · sorted by best
            </p>
          </div>
          <div className="flex items-center gap-1.5">
            <button
              type="button"
              onClick={() => collapseAll(true)}
              className={`rounded-full border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900 ${ring}`}
            >
              Collapse all
            </button>
            <button
              type="button"
              onClick={() => collapseAll(false)}
              className={`rounded-full border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900 ${ring}`}
            >
              Expand all
            </button>
          </div>
        </header>

        <ul className="mt-7 list-none space-y-0 p-0">
          {tree.map((node) => (
            <Comment
              key={node.id}
              node={node}
              depth={0}
              replyingTo={replyingTo}
              onVote={handleVote}
              onToggle={handleToggle}
              onReplyOpen={setReplyingTo}
              onReplySubmit={handleReplySubmit}
              reduced={reduced}
            />
          ))}
        </ul>

        <p aria-live="polite" className="sr-only">
          {newest}
        </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 →