Display

Comment thread

A threaded discussion: comments with replies one level deep, reactions, edited and deleted states.

Built on
<article><ol><time>aria-pressed
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.7 kB
Built from
AvatarButtonIcon
  1. Priya Raman1 hour ago

    Ship it on Friday?
    1. Sam Lee40 min ago

      Yes, once QA passes.
Code
"use client";

import { useState } from "react";
import { CommentItem, CommentThread } from "@/components/equip/comment-thread";

export default function Demo() {
  const [liked, setLiked] = useState(false);
  return (
    <CommentThread label="Comments on the launch plan" style={{ width: 216 }}>
      <CommentItem
        author="Priya Raman"
        time="1 hour ago"
        reactions={[{ id: "up", label: "Thumbs up", icon: "thumbs-up", count: liked ? 4 : 3, pressed: liked }]}
        onReact={() => setLiked((value) => !value)}
        replies={
          <CommentItem author="Sam Lee" time="40 min ago">
            Yes, once QA passes.
          </CommentItem>
        }
      >
        Ship it on Friday?
      </CommentItem>
    </CommentThread>
  );
}

Examples

Reactions, replies and a composer

The part ships no editor: the composer slot takes whatever you use, here a plain textarea with a label.

  1. Ingrid HolmEdited

    The tax line should use the reduced rate for the Lisbon office. Can someone update the template before we send it?
    1. Kwame Mensah1 hour ago

      Agreed. I can take the copy changes.
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { CommentItem, CommentThread } from "@/components/equip/comment-thread";

type Reply = { id: number; text: string };

export default function Example() {
  const [up, setUp] = useState(true);
  const [heart, setHeart] = useState(false);
  const [draft, setDraft] = useState("");
  const [replies, setReplies] = useState<Reply[]>([{ id: 1, text: "Agreed. I can take the copy changes." }]);

  function send() {
    const text = draft.trim();
    if (!text) return;
    setReplies((list) => [...list, { id: list.length + 1, text }]);
    setDraft("");
  }

  return (
    <CommentThread label="Comments on Invoice 2041" style={{ maxWidth: 440 }}>
      <CommentItem
        author="Ingrid Holm"
        time="2 hours ago"
        dateTime="2026-03-12T11:20"
        edited
        reactions={[
          { id: "up", label: "Thumbs up", icon: "thumbs-up", count: up ? 3 : 2, pressed: up },
          { id: "heart", label: "Heart", icon: "heart", count: heart ? 2 : 1, pressed: heart },
        ]}
        onReact={(id) => (id === "up" ? setUp((v) => !v) : setHeart((v) => !v))}
        actions={
          <Button size="s" variant="ghost" onClick={() => document.getElementById("invoice-2041-reply")?.focus()}>
            Reply
          </Button>
        }
        replies={replies.map((reply) => (
          <CommentItem key={reply.id} author="Kwame Mensah" time={reply.id === 1 ? "1 hour ago" : "Just now"}>
            {reply.text}
          </CommentItem>
        ))}
        composer={
          <div style={{ display: "grid", gap: 6 }}>
            <label htmlFor="invoice-2041-reply" style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>
              Reply to Ingrid
            </label>
            <textarea
              id="invoice-2041-reply"
              rows={2}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              style={{
                boxSizing: "border-box",
                width: "100%",
                padding: "8px 12px",
                border: "1px solid var(--eq-line-strong)",
                borderRadius: "var(--eq-radius-m)",
                background: "var(--eq-raised)",
                color: "var(--eq-ink)",
                font: "inherit",
                resize: "vertical",
              }}
            />
            <div>
              <Button size="s" onClick={send}>
                Reply
              </Button>
            </div>
          </div>
        }
      >
        The tax line should use the reduced rate for the Lisbon office. Can someone update the template before we send it?
      </CommentItem>
    </CommentThread>
  );
}

Deleted and edited

A deleted comment keeps its place and its replies, so the conversation under it still makes sense.

  1. This comment was deleted

    1. Omar HaddadYesterday

      Good catch, the annual plan price was out of date.
    2. Yuki TanakaTodayEdited

      Fixed in the new build, along with the currency switcher.
  2. Tomás FerreiraToday

    Looks right on mobile now.
Code
import { CommentItem, CommentThread } from "@/components/equip/comment-thread";

export default function Example() {
  return (
    <CommentThread label="Comments on the pricing page" style={{ maxWidth: 440 }}>
      <CommentItem
        author="Lena Vogel"
        time="Yesterday"
        deleted
        replies={
          <>
            <CommentItem author="Omar Haddad" time="Yesterday">
              Good catch, the annual plan price was out of date.
            </CommentItem>
            <CommentItem author="Yuki Tanaka" time="Today" edited>
              Fixed in the new build, along with the currency switcher.
            </CommentItem>
          </>
        }
      />
      <CommentItem author="Tomás Ferreira" time="Today">
        Looks right on mobile now.
      </CommentItem>
    </CommentThread>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneCommentThread: names the thread for assistive tech.
composerReactNodenoneCommentThread: a composer for a new comment after the thread. CommentItem: a reply composer under its replies.
authorstringnoneCommentItem: who wrote it. Also gives the avatar its initials.
srcstringnoneCommentItem: avatar image URL.
timestringnoneCommentItem: when, already formatted, such as 2 hours ago.
dateTimestringnoneCommentItem: ISO date for a time element.
editedbooleanfalseCommentItem: shows an Edited marker after the time.
deletedbooleanfalseCommentItem: replaces the comment with This comment was deleted. Its replies stay.
reactions{ id: string; label: string; icon?: IconName; emoji?: string; count: number; pressed?: boolean }[]noneCommentItem: reaction toggles under the body.
onReact(id: string) => voidnoneCommentItem: called with a reaction's id when it is toggled.
actionsReactNodenoneCommentItem: small buttons such as Reply, after the reactions.
repliesReactNodenoneCommentItem: reply CommentItems. Only top-level comments render them, so nesting stops at one level.
childrenReactNodenoneCommentItem: the comment text.
...restComponentProps<"div"> | ComponentProps<"li">noneCommentThread spreads onto its div, CommentItem onto its li.

Anatomy

root.eq-comment-thread
list.eq-comment-thread-listThe ordered list of top-level comments, named by label.
comment.eq-comment-thread-commentdata-deleted when deleted.
article.eq-comment-thread-articleNamed Comment by Priya Raman, or Reply by.
avatar.eq-comment-thread-avatar
ghost.eq-comment-thread-ghostA dashed circle in place of a deleted comment's avatar.
main.eq-comment-thread-main
meta.eq-comment-thread-meta
author.eq-comment-thread-author
time.eq-comment-thread-time
edited.eq-comment-thread-edited
body.eq-comment-thread-body
deleted.eq-comment-thread-deletedThe placeholder text.
footer.eq-comment-thread-footerReactions and actions.
reactions.eq-comment-thread-reactionsA group named Reactions.
reaction.eq-comment-thread-reactionA toggle Button with aria-pressed.
emoji.eq-comment-thread-emoji
actions.eq-comment-thread-actions
replies.eq-comment-thread-repliesNested ordered list, indented past the avatar.
composer.eq-comment-thread-composerYour editor, after the replies or after the thread.

Keyboard

TabMoves through reactions, actions and the composer in reading order.
EnterSpaceOn a reaction, toggles it.

Notes

  • Distinct from Chat message, which is a turn in a conversation with an assistant; this is threaded discussion between people.
  • Each reaction is a toggle button named with its count, such as Thumbs up, 3, and announced as pressed when the viewer has reacted.
  • Replies nest exactly one level: a CommentItem inside replies ignores replies of its own, so deep threads flatten under their top comment.
  • A deleted comment drops its author, reactions and actions but keeps its place and its replies, so the discussion under it still reads.
  • The part ships no editor; pass your own into composer, with a visible label.
  • Avatars are hidden from assistive tech because the author is written beside them.
  • It is a client part because reactions take a handler; times are strings, so the server render stays deterministic.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/comment-thread.tsx165 lines
"use client";

import { createContext, useContext, type ComponentProps, type ReactNode } from "react";
import { Avatar } from "./avatar";
import { Button } from "./button";
import { Icon, type IconName } from "./icon";
import "./comment-thread.css";

function cx(base: string, className: string | undefined) {
  return [base, className].filter(Boolean).join(" ");
}

/* True inside a comment's replies, so a reply cannot nest replies of its own. */
const InReplies = createContext(false);

export type CommentThreadProps = ComponentProps<"div"> & {
  /** Names the thread for assistive tech, e.g. "Comments on Invoice 2041". */
  label: string;
  /** A composer for a new top-level comment, placed after the thread. Bring your own editor. */
  composer?: ReactNode;
};

export function CommentThread({ label, composer, className, children, ...rest }: CommentThreadProps) {
  return (
    <div {...rest} className={cx("eq-comment-thread", className)}>
      <ol className="eq-comment-thread-list" aria-label={label}>
        {children}
      </ol>
      {composer != null && <div className="eq-comment-thread-composer">{composer}</div>}
    </div>
  );
}

export type CommentReaction = {
  /** Passed back to onReact. */
  id: string;
  /** What it means, read with the count, e.g. "Thumbs up". */
  label: string;
  /** An icon name, such as "thumbs-up" or "heart". */
  icon?: IconName;
  /** Or an emoji, when there is no icon for it. */
  emoji?: string;
  count: number;
  /** The viewer has reacted with this one. */
  pressed?: boolean;
};

export type CommentItemProps = Omit<ComponentProps<"li">, "children"> & {
  /** Who wrote it. Also gives the avatar its initials. */
  author: string;
  /** Avatar image URL. */
  src?: string;
  /** When, already formatted, e.g. "2 hours ago". */
  time: string;
  /** ISO date for a time element. */
  dateTime?: string;
  /** Shows an Edited marker after the time. */
  edited?: boolean;
  /** Replaces the comment with a placeholder. Its replies stay. */
  deleted?: boolean;
  /** Reaction toggles shown under the body. */
  reactions?: CommentReaction[];
  /** Called with a reaction's id when it is toggled. */
  onReact?: (id: string) => void;
  /** Small buttons such as Reply or Edit, after the reactions. */
  actions?: ReactNode;
  /** CommentItem elements. Only top-level comments render replies: nesting stops at one level. */
  replies?: ReactNode;
  /** A reply composer under the replies. Bring your own editor. */
  composer?: ReactNode;
  /** The comment text. */
  children?: ReactNode;
};

export function CommentItem({
  author,
  src,
  time,
  dateTime,
  edited = false,
  deleted = false,
  reactions,
  onReact,
  actions,
  replies,
  composer,
  className,
  children,
  ...rest
}: CommentItemProps) {
  const isReply = useContext(InReplies);
  const name = deleted ? (isReply ? "Deleted reply" : "Deleted comment") : `${isReply ? "Reply" : "Comment"} by ${author}`;
  const showReplies = !isReply && replies != null;

  return (
    <li {...rest} className={cx("eq-comment-thread-comment", className)} data-deleted={deleted || undefined}>
      <article className="eq-comment-thread-article" aria-label={name}>
        {deleted ? (
          <span className="eq-comment-thread-ghost" aria-hidden="true" />
        ) : (
          <Avatar className="eq-comment-thread-avatar" name={author} src={src} alt="" size="s" />
        )}
        <div className="eq-comment-thread-main">
          {deleted ? (
            <p className="eq-comment-thread-deleted">This comment was deleted</p>
          ) : (
            <>
              <p className="eq-comment-thread-meta">
                <span className="eq-comment-thread-author">{author}</span>
                {dateTime ? (
                  <time className="eq-comment-thread-time" dateTime={dateTime}>
                    {time}
                  </time>
                ) : (
                  <span className="eq-comment-thread-time">{time}</span>
                )}
                {edited && <span className="eq-comment-thread-edited">Edited</span>}
              </p>
              {children != null && <div className="eq-comment-thread-body">{children}</div>}
              {((reactions != null && reactions.length > 0) || actions != null) && (
                <div className="eq-comment-thread-footer">
                  {reactions != null && reactions.length > 0 && (
                    <div className="eq-comment-thread-reactions" role="group" aria-label="Reactions">
                      {reactions.map((reaction) => (
                        <Button
                          key={reaction.id}
                          className="eq-comment-thread-reaction"
                          size="xs"
                          variant="outline"
                          pressed={reaction.pressed ?? false}
                          aria-label={`${reaction.label}, ${reaction.count}`}
                          lead={
                            reaction.icon ? (
                              <Icon name={reaction.icon} size={14} />
                            ) : (
                              <span className="eq-comment-thread-emoji" aria-hidden="true">
                                {reaction.emoji}
                              </span>
                            )
                          }
                          onClick={() => onReact?.(reaction.id)}
                        >
                          {reaction.count}
                        </Button>
                      ))}
                    </div>
                  )}
                  {actions != null && <div className="eq-comment-thread-actions">{actions}</div>}
                </div>
              )}
            </>
          )}
        </div>
      </article>
      {showReplies && (
        <InReplies.Provider value={true}>
          <ol className="eq-comment-thread-replies" aria-label={deleted ? "Replies" : `Replies to ${author}`}>
            {replies}
          </ol>
        </InReplies.Provider>
      )}
      {composer != null && <div className="eq-comment-thread-composer">{composer}</div>}
    </li>
  );
}
components/equip/comment-thread.css154 lines
@layer equip {
  .eq-comment-thread {
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
  }

  .eq-comment-thread-list,
  .eq-comment-thread-replies {
    display: grid;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-comment-thread-list {
    gap: var(--eq-space-5);
  }

  .eq-comment-thread-replies {
    gap: var(--eq-space-3);
  }

  /* Replies and the reply composer line up with the parent's text, past its avatar. */
  .eq-comment-thread-replies,
  .eq-comment-thread-comment > .eq-comment-thread-composer {
    margin-top: var(--eq-space-2);
    margin-inline-start: calc(var(--eq-h-s) + var(--eq-space-2));
  }

  .eq-comment-thread-replies .eq-comment-thread-comment > .eq-comment-thread-composer {
    margin-inline-start: 0;
  }

  .eq-comment-thread > .eq-comment-thread-composer {
    margin-top: var(--eq-space-4);
  }

  .eq-comment-thread-comment {
    min-width: 0;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  /* A new comment lands rather than appears. */
  @starting-style {
    .eq-comment-thread-comment {
      opacity: 0;
      translate: 0 4px;
    }
  }

  .eq-comment-thread-article {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: var(--eq-space-2);
    align-items: start;
  }

  .eq-comment-thread-avatar {
    flex: none;
  }

  /* Stands in for the avatar of a deleted comment: the outline of someone not there. */
  .eq-comment-thread-ghost {
    box-sizing: border-box;
    width: var(--eq-h-s);
    height: var(--eq-h-s);
    border: 1px dashed var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
  }

  .eq-comment-thread-main {
    display: grid;
    gap: 2px;
    min-width: 0;
  }

  .eq-comment-thread-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: var(--eq-space-2);
    margin: 0;
    line-height: 18px;
  }

  .eq-comment-thread-author {
    min-width: 0;
    font-weight: var(--eq-weight-strong);
    overflow-wrap: anywhere;
  }

  .eq-comment-thread-time,
  .eq-comment-thread-edited {
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  .eq-comment-thread-body {
    min-width: 0;
    overflow-wrap: anywhere;
  }

  .eq-comment-thread-body > :first-child {
    margin-top: 0;
  }

  .eq-comment-thread-body > :last-child {
    margin-bottom: 0;
  }

  .eq-comment-thread-body > :is(p, ul, ol, blockquote) {
    margin-block: 0 var(--eq-space-2);
  }

  .eq-comment-thread-deleted {
    display: flex;
    align-items: center;
    min-height: var(--eq-h-s);
    margin: 0;
    color: var(--eq-ink-3);
    font-style: italic;
  }

  .eq-comment-thread-footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-2);
    margin-top: var(--eq-space-1);
  }

  .eq-comment-thread-reactions,
  .eq-comment-thread-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-1);
  }

  /* A reaction is a small toggle: the shared Button, pressed when the viewer has reacted. */
  .eq-comment-thread-reaction {
    font-variant-numeric: tabular-nums;
  }

  .eq-comment-thread-emoji {
    font-size: var(--eq-text-s);
    line-height: 1;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index