klyv-ui

Comment Thread

Component 7

About

A nested comment/discussion thread for a document, design or pull request, with capped-depth replies, per-comment edit/delete/react controls and a resolve toggle that hides composers on a settled discussion.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Comment Thread to your project and fits it to your theme.

Code

How Comment Thread is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import CommentThread from "@/components/ui/comment-thread";

type Reaction = { emoji: string; count: number; reacted: boolean };

type Comment = {
  id: string;
  author: { id: string; name: string };
  body: string;
  createdAt: Date;
  editedAt?: Date;
  reactions?: Reaction[];
  replies?: Comment[];
};

const CURRENT_USER_ID = "priya";

const INITIAL_COMMENTS: Comment[] = [
  {
    id: "c1",
    author: { id: "amara", name: "Amara Okafor" },
    body: "Should the empty state link to the import guide, or open the importer directly?",
    createdAt: new Date(Date.now() - 1000 * 60 * 60 * 3),
    reactions: [
      { emoji: "👍", count: 2, reacted: false },
      { emoji: "🎉", count: 0, reacted: false },
      { emoji: "👀", count: 0, reacted: false },
    ],
    replies: [
      {
        id: "c2",
        author: { id: "priya", name: "Priya Raman" },
        body: "Open the importer. The guide is one click away from there, and most people already have a CSV.",
        createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
        reactions: [
          { emoji: "👍", count: 3, reacted: true },
          { emoji: "🎉", count: 0, reacted: false },
          { emoji: "👀", count: 0, reacted: false },
        ],
        replies: [
          {
            id: "c3",
            author: { id: "jonas", name: "Jonas Weber" },
            body: "Agreed. Our funnel shows 70% drop-off on the guide page.",
            createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
            reactions: [
              { emoji: "👍", count: 0, reacted: false },
              { emoji: "🎉", count: 0, reacted: false },
              { emoji: "👀", count: 0, reacted: false },
            ],
          },
        ],
      },
    ],
  },
];

function nextId() {
  return Math.random().toString(36).slice(2, 9);
}

function mapTree(
  list: Comment[],
  id: string,
  fn: (c: Comment) => Comment,
): Comment[] {
  return list.map(function (comment) {
    if (comment.id === id) return fn(comment);
    if (comment.replies && comment.replies.length)
      return Object.assign({}, comment, {
        replies: mapTree(comment.replies, id, fn),
      });
    return comment;
  });
}

function removeFromTree(list: Comment[], id: string): Comment[] {
  return list
    .filter(function (comment) {
      return comment.id !== id;
    })
    .map(function (comment) {
      return comment.replies && comment.replies.length
        ? Object.assign({}, comment, {
            replies: removeFromTree(comment.replies, id),
          })
        : comment;
    });
}

export default function CommentThreadDemo() {
  const [comments, setComments] = React.useState<Comment[]>(INITIAL_COMMENTS);
  const [resolved, setResolved] = React.useState(false);

  function handleReply(parentId: string | null, body: string) {
    const reply: Comment = {
      id: nextId(),
      author: { id: CURRENT_USER_ID, name: "Priya Raman" },
      body: body,
      createdAt: new Date(),
    };
    if (parentId === null) {
      setComments(function (current) {
        return current.concat([reply]);
      });
      return;
    }
    setComments(function (current) {
      return mapTree(current, parentId, function (comment) {
        return Object.assign({}, comment, {
          replies: (comment.replies || []).concat([reply]),
        });
      });
    });
  }

  function handleEdit(id: string, body: string) {
    setComments(function (current) {
      return mapTree(current, id, function (comment) {
        return Object.assign({}, comment, { body: body, editedAt: new Date() });
      });
    });
  }

  function handleDelete(id: string) {
    setComments(function (current) {
      return removeFromTree(current, id);
    });
  }

  function handleReact(id: string, emoji: string) {
    setComments(function (current) {
      return mapTree(current, id, function (comment) {
        return Object.assign({}, comment, {
          reactions: (comment.reactions || []).map(function (reaction) {
            return reaction.emoji === emoji
              ? Object.assign({}, reaction, {
                  reacted: !reaction.reacted,
                  count: reaction.count + (reaction.reacted ? -1 : 1),
                })
              : reaction;
          }),
        });
      });
    });
  }

  return (
    <div className="mx-auto w-full max-w-xl rounded-[var(--radius-card)] border border-line bg-surface p-6">
      <CommentThread
        comments={comments}
        currentUserId={CURRENT_USER_ID}
        onReply={handleReply}
        onEdit={handleEdit}
        onDelete={handleDelete}
        onReact={handleReact}
        resolved={resolved}
        onResolvedChange={setResolved}
      />
    </div>
  );
}

More by klyv-ui

View all
  • Gantt Chart

    Gantt Chart

    56
  • Klyv Animated Beam

    Klyv Animated Beam

    47
  • Pareto Chart

    Pareto Chart

    40
  • Shader Canvas

    Shader Canvas

    35
  • Sunburst Chart

    Sunburst Chart

    23
  • Morphing Text

    Morphing Text

    21
  • Gooey Loader

    Gooey Loader

    20
  • Gravity Tags

    Gravity Tags

    17