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
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.
- The tax line should use the reduced rate for the Lisbon office. Can someone update the template before we send it?
- 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.
This comment was deleted
- Good catch, the annual plan price was out of date.
- Fixed in the new build, along with the currency switcher.
- 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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | CommentThread: names the thread for assistive tech. |
composer | ReactNode | none | CommentThread: a composer for a new comment after the thread. CommentItem: a reply composer under its replies. |
author | string | none | CommentItem: who wrote it. Also gives the avatar its initials. |
src | string | none | CommentItem: avatar image URL. |
time | string | none | CommentItem: when, already formatted, such as 2 hours ago. |
dateTime | string | none | CommentItem: ISO date for a time element. |
edited | boolean | false | CommentItem: shows an Edited marker after the time. |
deleted | boolean | false | CommentItem: replaces the comment with This comment was deleted. Its replies stay. |
reactions | { id: string; label: string; icon?: IconName; emoji?: string; count: number; pressed?: boolean }[] | none | CommentItem: reaction toggles under the body. |
onReact | (id: string) => void | none | CommentItem: called with a reaction's id when it is toggled. |
actions | ReactNode | none | CommentItem: small buttons such as Reply, after the reactions. |
replies | ReactNode | none | CommentItem: reply CommentItems. Only top-level comments render them, so nesting stops at one level. |
children | ReactNode | none | CommentItem: the comment text. |
...rest | ComponentProps<"div"> | ComponentProps<"li"> | none | CommentThread spreads onto its div, CommentItem onto its li. |
Anatomy
| root | .eq-comment-thread | |
|---|---|---|
| list | .eq-comment-thread-list | The ordered list of top-level comments, named by label. |
| comment | .eq-comment-thread-comment | data-deleted when deleted. |
| article | .eq-comment-thread-article | Named Comment by Priya Raman, or Reply by. |
| avatar | .eq-comment-thread-avatar | |
| ghost | .eq-comment-thread-ghost | A 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-deleted | The placeholder text. |
| footer | .eq-comment-thread-footer | Reactions and actions. |
| reactions | .eq-comment-thread-reactions | A group named Reactions. |
| reaction | .eq-comment-thread-reaction | A toggle Button with aria-pressed. |
| emoji | .eq-comment-thread-emoji | |
| actions | .eq-comment-thread-actions | |
| replies | .eq-comment-thread-replies | Nested ordered list, indented past the avatar. |
| composer | .eq-comment-thread-composer | Your editor, after the replies or after the thread. |
Keyboard
| Tab | Moves through reactions, actions and the composer in reading order. |
|---|---|
| EnterSpace | On 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;
}
}
Priya Raman1 hour ago
Sam Lee40 min ago