Skip to content
Component

Comment thread

A threaded discussion with replies, reactions and resolve or reopen.

  • #arc
  • #component
  • #comments
  • #discussion
  • #replies
  • #collaboration

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/comment-thread.json

comment-thread.module.csscss
.root {
  width: 100%;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-panel);
  background: var(--surface);
  color: var(--foreground);
  font-family: var(--font-body);
  font-size: var(--text-sm);
  letter-spacing: var(--tracking-body);
  line-height: var(--leading-body);
  transition: border-radius var(--duration-spring) var(--ease-spring);
}
.root *, .root *::before, .root *::after { box-sizing: border-box; }
.root[data-resolved] { border-radius: 22px; }
.autoHeight { overflow: hidden; border-radius: inherit; }
.autoInner { position: relative; }

/* Header */
.thread { display: grid; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 12px 8px 18px; }
.heading { display: grid; min-width: 0; }
.title { margin: 0; overflow: hidden; font-size: var(--text-sm); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.subtitle { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.resolve,
.reopen {
  display: inline-flex;
  height: 32px;
  flex: none;
  align-items: center;
  gap: 6px;
  padding: 0 12px 0 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--foreground);
  cursor: pointer;
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 500;
  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.resolve > svg { color: var(--success); }
.resolve:active:not(:disabled), .reopen:active { transform: scale(.97); }
.resolve:disabled { opacity: .45; cursor: default; }

/* Comments */
.list, .replies { display: grid; margin: 0; padding: 0; list-style: none; }
.list { padding: 0 8px; }
.item { overflow: clip; overflow-clip-margin: 4px; }
.repliesWrap { overflow: clip; }
/* Replies hang from a quiet rail under their parent's avatar. */
.replies { position: relative; margin-left: 24px; padding-left: 12px; }
.replies::before { content: ""; position: absolute; top: 2px; bottom: 14px; left: 0; width: 1px; background: var(--border); }

.comment { display: flex; gap: 10px; padding: 10px; border-radius: 16px; }
.avatar { display: inline-grid; flex: none; place-items: center; overflow: hidden; border-radius: 50%; background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; letter-spacing: 0; object-fit: cover; }
.deletedDot { width: 28px; height: 28px; flex: none; border: 1px dashed var(--border-strong); border-radius: 50%; }
.main { display: grid; min-width: 0; flex: 1; gap: 2px; }
.meta { display: flex; min-width: 0; align-items: baseline; gap: 8px; }
.name { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.time { flex: none; color: var(--text-muted); font-size: var(--text-xs); }
.body { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; white-space: pre-wrap; text-wrap: pretty; }
.mention { color: var(--accent-strong); font-weight: 500; }
.deleted { margin: 4px 0 0; color: var(--text-muted); font-style: italic; }

.footer { display: flex; min-height: 30px; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px; }
.reactions { display: flex; flex-wrap: wrap; gap: 4px; }
.reaction {
  display: inline-flex;
  height: 26px;
  align-items: center;
  gap: 5px;
  padding: 0 9px 0 7px;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--text-secondary);
  cursor: pointer;
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 500;
  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}
.reaction[aria-pressed="true"] { border-color: transparent; background: var(--accent-subtle); color: var(--foreground); }
.reaction:active { transform: scale(.94); }
.count { position: relative; display: inline-grid; min-width: 1ch; overflow: hidden; font-variant-numeric: tabular-nums; }
.count > span { grid-area: 1 / 1; }

/* Actions sit quietly after the reactions and come forward on hover or focus; touch screens always show them. */
.actions { display: flex; min-height: 28px; flex-wrap: wrap; align-items: center; gap: 2px; transition: opacity var(--duration-fast) var(--ease-standard); }
.action,
.iconAction {
  display: inline-flex;
  height: 28px;
  align-items: center;
  justify-content: center;
  padding: 0 8px;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 500;
  -webkit-tap-highlight-color: transparent;
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}
.iconAction { width: 28px; padding: 0; }
.action[data-tone="danger"] { color: var(--danger); }
.prompt { margin-right: 4px; color: var(--foreground); font-size: var(--text-xs); }
.emoji { display: grid; width: 28px; height: 28px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; font-size: var(--text-base); line-height: 1; transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.emoji[aria-pressed="true"] { background: var(--accent-subtle); }
.emoji:active { transform: scale(.88); }

.toggle { display: inline-flex; width: fit-content; height: 28px; align-items: center; gap: 6px; margin: 2px 0 0 -6px; padding: 0 8px 0 6px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); font-weight: 500; transition: background-color var(--duration-fast) var(--ease-standard); }
.toggleIcon { display: grid; place-items: center; color: var(--text-muted); }
.stack { display: inline-flex; flex: none; padding-left: 4px; }
.stack > * { margin-left: -5px; box-shadow: 0 0 0 2px var(--surface); }

.empty { margin: 0; padding: 8px 18px 16px; color: var(--text-muted); }

/* Composer */
.composerArea { display: grid; margin-top: 4px; padding: 10px 12px 12px; border-top: 1px solid var(--border-subtle); }
.replying { overflow: clip; }
.replyingInner { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px 38px; color: var(--text-secondary); font-size: var(--text-xs); }
.replyingInner .iconAction { width: 22px; height: 22px; margin-left: 2px; }
.replyingName { color: var(--foreground); font-weight: 500; }

.composer { display: grid; align-items: start; gap: 8px 10px; grid-template-columns: auto minmax(0, 1fr) auto; }
.composer > .avatar { margin-top: 5px; }
.composer[data-compact] { grid-template-columns: minmax(0, 1fr); margin-top: 4px; }
.fieldWrap { display: grid; min-width: 0; }
.field {
  width: 100%;
  min-height: 38px;
  max-height: 160px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--surface);
  color: var(--foreground);
  font: inherit;
  resize: none;
  field-sizing: content;
  transition: border-color var(--duration-fast) var(--ease-standard);
}
.field::placeholder { color: var(--text-muted); }
.field:focus { border-color: var(--border-strong); }
.composerActions { display: flex; align-items: center; gap: 4px; }
.composer[data-compact] .composerActions { justify-content: flex-end; }
.send,
.ghost { height: 38px; padding: 0 14px; border: 0; border-radius: var(--radius-pill); cursor: pointer; font: inherit; font-weight: 500; transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.composer[data-compact] :is(.send, .ghost) { height: 32px; font-size: var(--text-xs); }
.send { background: var(--foreground); color: var(--background); }
.send:disabled { opacity: .35; cursor: default; }
.send:active:not(:disabled), .ghost:active { transform: scale(.97); }
.ghost { background: transparent; color: var(--text-secondary); }

.suggestions { display: grid; margin: 6px 0 0; padding: 4px; overflow: clip; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); list-style: none; }
.suggestion { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 8px; border-radius: 10px; cursor: pointer; }
.suggestion[aria-selected="true"] { background: var(--surface-muted); }

/* Resolved chip */
.resolved { display: flex; align-items: center; gap: 12px; padding: 10px 10px 10px 14px; }
.resolvedIcon { display: grid; width: 24px; height: 24px; flex: none; place-items: center; border-radius: 50%; background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }
.resolvedText { display: grid; min-width: 0; flex: 1; }
.resolvedTitle { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.resolvedSubject { color: var(--text-secondary); font-weight: 400; }
.resolvedMeta { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }

@media (hover: hover) and (pointer: fine) {
  .comment .actions:not([data-open]) { opacity: 0; }
  .comment:hover .actions,
  .comment:focus-within .actions { opacity: 1; }
  .action:hover, .iconAction:hover, .toggle:hover, .ghost:hover { background: var(--surface-muted); color: var(--foreground); }
  .action[data-tone="danger"]:hover { background: color-mix(in oklab, var(--danger) 9%, transparent); color: var(--danger); }
  .reaction:hover { border-color: var(--border-strong); }
  .reaction[aria-pressed="true"]:hover { border-color: transparent; }
  .emoji:hover { background: var(--surface-muted); }
  .resolve:hover:not(:disabled), .reopen:hover { border-color: var(--border-strong); background: var(--surface-muted); }
  .send:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 86%, var(--background)); }
}
.action:focus-visible, .iconAction:focus-visible, .toggle:focus-visible, .emoji:focus-visible { background: var(--surface-muted); color: var(--foreground); }

@media (max-width: 420px) {
  .composer { grid-template-columns: minmax(0, 1fr) auto; }
  .composer > .avatar { display: none; }
  .replyingInner { padding-left: 2px; }
  .replies { margin-left: 12px; }
}

@media (prefers-reduced-motion: reduce) {
  .root, .actions, .reaction, .action, .iconAction, .emoji, .send, .ghost, .resolve, .reopen { transition: none; }
}
comment-thread.tsxtsx
"use client";

import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CornerDownRightIcon, SmileIcon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./comment-thread.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";

export interface CommentAuthor {
  id: string;
  name: string;
  /** Square image URL. Initials are shown without one. */
  avatar?: string;
}

export interface CommentReaction {
  emoji: string;
  /** Ids of the people who reacted. */
  users: string[];
}

export interface ThreadComment {
  id: string;
  author: CommentAuthor;
  /** Plain text. "@Full Name" mentions of known people are highlighted. */
  body: string;
  /** Display label such as "2h" or "Sep 18". */
  createdAt: string;
  edited?: boolean;
  /** A deleted comment that still has replies keeps its place as a quiet placeholder. */
  deleted?: boolean;
  reactions?: CommentReaction[];
  replies?: ThreadComment[];
}

/** What changed, for syncing to a server. `comments` on `onCommentsChange` is always the full next tree. */
export type CommentThreadEvent =
  | { type: "reply"; comment: ThreadComment; parentId: string | null }
  | { type: "edit"; id: string; body: string }
  | { type: "delete"; id: string }
  | { type: "react"; id: string; emoji: string; added: boolean };

/** Every word the thread shows or announces. Each one has a Korean default; words with a count or a name are functions, so an app can apply its own plural rules. */
export interface CommentThreadLabels {
  /** Accessible name of the whole thread. */
  thread?: string;
  /** Header and resolved chip, such as "3 comments". */
  commentCount?: (count: number) => string;
  /** Resolved chip, such as "2 people". */
  peopleCount?: (count: number) => string;
  resolve?: string;
  /** Title of the resolved chip. */
  resolved?: string;
  reopen?: string;
  /** Shown when there are no comments. */
  empty?: string;
  reply?: string;
  edit?: string;
  delete?: string;
  cancel?: string;
  send?: string;
  /** Saves an edit. */
  save?: string;
  /** Placeholder and accessible name of the edit field. */
  editComment?: string;
  /** After the time of an edited comment. */
  edited?: string;
  deleteConfirm?: string;
  /** Placeholder text of a deleted comment that still has replies. */
  deletedText?: string;
  /** Accessible name of that placeholder. */
  deletedComment?: string;
  /** Above the composer while replying. The name inside it is highlighted. */
  replyingTo?: (name: string) => string;
  cancelReply?: string;
  showReplies?: (count: number) => string;
  hideReplies?: string;
  addReaction?: string;
  closeReactions?: string;
  /** Accessible name of the reaction picker. */
  reactions?: string;
  /** Accessible name of an emoji in the picker. */
  reactWith?: (emoji: string) => string;
  /** Accessible name of a reaction under a comment. */
  reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;
  /** Accessible name of the @mention suggestions. */
  mentions?: string;
}

const DEFAULT_LABELS: Required<CommentThreadLabels> = {
  thread: "댓글",
  commentCount: count => `댓글 ${count}개`,
  peopleCount: count => `${count}명`,
  resolve: "해결하기",
  resolved: "해결됨",
  reopen: "다시 열기",
  empty: "아직 댓글이 없어요. 아래에서 대화를 시작해 보세요.",
  reply: "답글 달기",
  edit: "수정하기",
  delete: "삭제하기",
  cancel: "취소하기",
  send: "보내기",
  save: "저장하기",
  editComment: "댓글 수정하기",
  edited: "수정함",
  deleteConfirm: "이 댓글을 삭제할까요?",
  deletedText: "삭제된 댓글이에요",
  deletedComment: "삭제된 댓글",
  replyingTo: name => `${name}님에게 답글 쓰는 중`,
  cancelReply: "답글 취소하기",
  showReplies: count => `답글 ${count}개 보기`,
  hideReplies: "답글 숨기기",
  addReaction: "반응 추가하기",
  closeReactions: "반응 닫기",
  reactions: "반응",
  reactWith: emoji => `${emoji} 반응 남기기`,
  reactionCount: (emoji, count, includesYou) => `${emoji} ${count}명${includesYou ? ", 나도 남겼어요" : ""}`,
  mentions: "사람",
};

/** A key passed as undefined keeps its default too. */
const withDefaults = <T extends object>(defaults: Required<T>, given?: T): Required<T> =>
  ({ ...defaults, ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)) });

/**
 * A threaded discussion: replies nest under their comment and collapse, reactions toggle with a count that rolls,
 * authors edit and delete their own comments in place, mentions autocomplete in the composer, and resolving folds the
 * whole thread into a single chip that reopens it. Controlled with `comments` and `resolved`, or uncontrolled.
 */
export interface CommentThreadProps {
  comments?: ThreadComment[];
  defaultComments?: ThreadComment[];
  onCommentsChange?: (comments: ThreadComment[], event: CommentThreadEvent) => void;
  /** The person writing. Their comments can be edited and deleted. */
  currentUser: CommentAuthor;
  /** People who can be mentioned. Defaults to everyone in the thread. */
  people?: CommentAuthor[];
  resolved?: boolean;
  defaultResolved?: boolean;
  onResolvedChange?: (resolved: boolean) => void;
  /** What the thread is about, shown in its header, such as "Hero headline". */
  title?: ReactNode;
  /** Emoji offered by the reaction picker. */
  reactions?: string[];
  placeholder?: string;
  /** Replies deeper than this attach to the deepest allowed parent. Defaults to 2. */
  maxDepth?: number;
  /** Label for comments written now. */
  nowLabel?: string;
  /** Words for localization. Leave out any key to keep its Korean default. */
  labels?: CommentThreadLabels;
  className?: string;
}

type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const physical = (visualDuration: number, bounce: number): Transition => {
  const root = 2 * Math.PI / (visualDuration * 1.2);
  return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const HEIGHT = physical(.42, 0);
const DEFAULT_REACTIONS = ["👍", "❤️", "🎉", "👀", "🚀", "✅"];

const subscribe = () => () => {};
function useReducedFlag() {
  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}

/* Tree helpers. Every change returns a new tree so controlled parents can store it as is. */
const mapTree = (list: ThreadComment[], id: string, fn: (comment: ThreadComment) => ThreadComment | null): ThreadComment[] =>
  list.flatMap(comment => {
    if (comment.id === id) { const next = fn(comment); return next ? [next] : []; }
    return comment.replies?.length ? [{ ...comment, replies: mapTree(comment.replies, id, fn) }] : [comment];
  });
const countTree = (list: ThreadComment[]): number => list.reduce((sum, comment) => sum + (comment.deleted ? 0 : 1) + countTree(comment.replies ?? []), 0);
const authorsOf = (list: ThreadComment[], into = new Map<string, CommentAuthor>()) => {
  list.forEach(comment => { if (!comment.deleted) into.set(comment.author.id, comment.author); authorsOf(comment.replies ?? [], into); });
  return into;
};
/** Path of ids from the root to `id`. */
const pathTo = (list: ThreadComment[], id: string, trail: string[] = []): string[] | null => {
  for (const comment of list) {
    if (comment.id === id) return [...trail, id];
    const found = pathTo(comment.replies ?? [], id, [...trail, comment.id]);
    if (found) return found;
  }
  return null;
};
const find = (list: ThreadComment[], id: string): ThreadComment | null => {
  for (const comment of list) {
    if (comment.id === id) return comment;
    const found = find(comment.replies ?? [], id);
    if (found) return found;
  }
  return null;
};
/** Wraps the first `part` inside `text`, so a translated sentence keeps its highlighted name wherever the language puts it. */
const highlight = (text: string, part: string, className?: string): ReactNode => {
  const at = part ? text.indexOf(part) : -1;
  return at < 0 ? text : <>{text.slice(0, at)}<span className={className}>{part}</span>{text.slice(at + part.length)}</>;
};
const initials = (name: string) => name.split(/\s+/).map(part => part[0]).slice(0, 2).join("").toUpperCase();
let serial = 0;
const newId = () => `c-${Date.now().toString(36)}-${(serial++).toString(36)}`;

function Avatar({ author, size = 28 }: { author: CommentAuthor; size?: number }) {
  return author.avatar
    // eslint-disable-next-line @next/next/no-img-element
    ? <img className={styles.avatar} src={author.avatar} alt="" width={size} height={size} style={{ width: size, height: size }} />
    : <span className={styles.avatar} style={{ width: size, height: size, fontSize: Math.round(size * .38) }} aria-hidden="true">{initials(author.name)}</span>;
}

/** Highlights "@Full Name" for known people. */
function Body({ text, people }: { text: string; people: CommentAuthor[] }) {
  const parts = useMemo(() => {
    const names = people.map(person => person.name).sort((a, b) => b.length - a.length).map(name => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
    if (!names.length) return [text];
    return text.split(new RegExp(`(@(?:${names.join("|")}))`, "g"));
  }, [people, text]);
  return <p className={styles.body}>{parts.map((part, index) => index % 2 ? <span key={index} className={styles.mention}>{part}</span> : part)}</p>;
}

const roll: Variants = {
  hidden: (dir: number) => ({ y: `${dir * 70}%`, opacity: 0 }),
  shown: { y: 0, opacity: 1 },
  gone: (dir: number) => ({ y: `${dir * -70}%`, opacity: 0 }),
};
const rollReduced: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1 }, gone: { opacity: 0 } };

/** Rolls a count up or down in place. */
function Count({ value, reduced }: { value: number; reduced: boolean }) {
  const [last, setLast] = useState(value);
  const [dir, setDir] = useState(1);
  if (value !== last) { setDir(value > last ? 1 : -1); setLast(value); }
  return <span className={styles.count}>
    <AnimatePresence mode="popLayout" initial={false} custom={dir}>
      <motion.span key={value} custom={dir} variants={reduced ? rollReduced : roll} initial="hidden" animate="shown" exit="gone"
        transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>{value}</motion.span>
    </AnimatePresence>
  </span>;
}

/** Follows its content's height exactly, except when `morphKey` changes: then it springs, so the thread and the resolved chip morph into each other. */
function AutoHeight({ children, reduced, morphKey }: { children: ReactNode; reduced: boolean; morphKey: string }) {
  const inner = useRef<HTMLDivElement>(null);
  const height = useMotionValue(0);
  const [measured, setMeasured] = useState(false);
  const morphing = useRef(false);
  const lastKey = useRef(morphKey);
  useLayoutEffect(() => {
    if (lastKey.current !== morphKey) { lastKey.current = morphKey; morphing.current = true; }
  }, [morphKey]);
  useLayoutEffect(() => {
    const node = inner.current;
    if (!node || typeof ResizeObserver === "undefined") return;
    // The border box keeps subpixel line heights, so the frame never clips a fraction of the last line.
    const observer = new ResizeObserver(([entry]) => {
      const next = entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
      if (morphing.current && !reduced) {
        morphing.current = false;
        animate(height, next, HEIGHT);
        return;
      }
      if (height.isAnimating()) { animate(height, next, HEIGHT); return; }
      height.jump(next);
      setMeasured(true);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [height, reduced]);
  return <motion.div className={styles.autoHeight} style={{ height: measured ? height : "auto" }}><div ref={inner} className={styles.autoInner}>{children}</div></motion.div>;
}

interface ComposerProps {
  people: CommentAuthor[];
  initial?: string;
  placeholder: string;
  submitLabel: string;
  autoFocus?: boolean;
  focusKey?: number;
  onSubmit: (body: string) => void;
  onCancel?: () => void;
  leading?: ReactNode;
  compact?: boolean;
  cancelLabel: string;
  mentionsLabel: string;
}

/** A growing text field with @mention suggestions. ⌘ or Ctrl + Enter sends; Escape cancels. */
function Composer({ people, initial = "", placeholder, submitLabel, autoFocus, focusKey, onSubmit, onCancel, leading, compact, cancelLabel, mentionsLabel }: ComposerProps) {
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const reduced = useReducedFlag();
  const field = useRef<HTMLTextAreaElement>(null);
  const [text, setText] = useState(initial);
  const [query, setQuery] = useState<{ start: number; term: string } | null>(null);
  const [active, setActive] = useState(0);
  const suggestions = useMemo(() => {
    if (!query) return [];
    const term = query.term.toLowerCase();
    return people.filter(person => person.name.toLowerCase().split(" ").some(part => part.startsWith(term)) || person.name.toLowerCase().startsWith(term)).slice(0, 5);
  }, [people, query]);
  const open = !!query && suggestions.length > 0;

  useEffect(() => {
    if (!autoFocus && !focusKey) return;
    const node = field.current;
    if (!node) return;
    node.focus({ preventScroll: true });
    node.setSelectionRange(node.value.length, node.value.length);
  }, [autoFocus, focusKey]);

  const readQuery = (value: string, caret: number) => {
    const match = /(^|\s)@([\p{L}]*)$/u.exec(value.slice(0, caret));
    setQuery(match ? { start: caret - match[2]!.length - 1, term: match[2]! } : null);
    setActive(0);
  };

  const insert = (person: CommentAuthor) => {
    const node = field.current;
    if (!node || !query) return;
    const caret = node.selectionStart ?? text.length;
    const next = `${text.slice(0, query.start)}@${person.name} ${text.slice(caret)}`;
    const at = query.start + person.name.length + 2;
    setText(next);
    setQuery(null);
    requestAnimationFrame(() => { node.focus(); node.setSelectionRange(at, at); });
  };

  const send = () => {
    const body = text.trim();
    if (!body) return;
    playUiSound("click");
    onSubmit(body);
    setText("");
    setQuery(null);
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
    if (open) {
      if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setActive(index => (index + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) % suggestions.length); return; }
      if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); insert(suggestions[active]!); return; }
      if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setQuery(null); return; }
    }
    if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); send(); return; }
    if (event.key === "Escape" && onCancel) { event.preventDefault(); event.stopPropagation(); onCancel(); }
  };

  return <div className={styles.composer} data-compact={compact || undefined}>
    {leading}
    <div className={styles.fieldWrap}>
      <textarea ref={field} className={styles.field} value={text} placeholder={placeholder} rows={1} aria-label={placeholder}
        role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${uid}-people`} aria-activedescendant={open ? `${uid}-person-${active}` : undefined}
        onChange={event => { setText(event.target.value); readQuery(event.target.value, event.target.selectionStart); }}
        onSelect={event => readQuery(event.currentTarget.value, event.currentTarget.selectionStart)}
        onBlur={() => setQuery(null)} onKeyDown={onKeyDown} />
      <AnimatePresence>
        {open && <motion.ul key="people" id={`${uid}-people`} role="listbox" aria-label={mentionsLabel} className={styles.suggestions}
          initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, height: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }} transition={reduced ? { duration: 0 } : { height: HEIGHT, opacity: { duration: motionTokens.duration.fast, ease: enter } }}>
          {suggestions.map((person, index) => <li key={person.id} id={`${uid}-person-${index}`} role="option" aria-selected={index === active} className={styles.suggestion}
            onPointerDown={event => event.preventDefault()} onPointerMove={() => setActive(index)} onClick={() => insert(person)}>
            <Avatar author={person} size={20} />{person.name}
          </li>)}
        </motion.ul>}
      </AnimatePresence>
    </div>
    <div className={styles.composerActions}>
      {onCancel && <button type="button" className={styles.ghost} onClick={onCancel}>{cancelLabel}</button>}
      <button type="button" className={styles.send} disabled={!text.trim() || text.trim() === initial.trim()} onClick={send}>{submitLabel}</button>
    </div>
  </div>;
}

type Ui = { editing: string | null; confirming: string | null; picker: string | null; collapsed: Record<string, boolean> };

export const CommentThread = forwardRef<HTMLElement, CommentThreadProps>(function CommentThread({
  comments, defaultComments = [], onCommentsChange, currentUser, people, resolved, defaultResolved = false, onResolvedChange,
  title, reactions = DEFAULT_REACTIONS, placeholder = "답글을 쓰거나 @로 사람을 불러 보세요", maxDepth = 2, nowLabel = "방금 전", labels, className,
}, forwardedRef) {
  const t = withDefaults(DEFAULT_LABELS, labels);
  const reduced = useReducedFlag();
  const rootRef = useRef<HTMLElement>(null);
  useImperativeHandle(forwardedRef, () => rootRef.current as HTMLElement);
  const [internal, setInternal] = useState(defaultComments);
  const list = comments ?? internal;
  const [internalResolved, setInternalResolved] = useState(defaultResolved);
  const isResolved = resolved ?? internalResolved;
  const [ui, setUi] = useState<Ui>({ editing: null, confirming: null, picker: null, collapsed: {} });
  const [replyTo, setReplyTo] = useState<{ id: string; name: string } | null>(null);
  const [focusKey, setFocusKey] = useState(0);

  const mentionable = useMemo(() => people ?? [...authorsOf(list, new Map([[currentUser.id, currentUser]])).values()], [currentUser, list, people]);
  const total = countTree(list);
  const participants = useMemo(() => [...authorsOf(list).values()], [list]);

  const commit = useCallback((next: ThreadComment[], event: CommentThreadEvent) => {
    if (comments === undefined) setInternal(next);
    onCommentsChange?.(next, event);
  }, [comments, onCommentsChange]);

  const setResolved = (next: boolean) => {
    if (resolved === undefined) setInternalResolved(next);
    onResolvedChange?.(next);
    setUi(current => ({ ...current, editing: null, confirming: null, picker: null }));
    setReplyTo(null);
    requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(next ? "[data-reopen]" : "[data-resolve]")?.focus({ preventScroll: true }));
  };

  const addReply = (body: string) => {
    const comment: ThreadComment = { id: newId(), author: currentUser, body, createdAt: nowLabel, reactions: [] };
    let parentId: string | null = null;
    if (replyTo) {
      const path = pathTo(list, replyTo.id) ?? [];
      parentId = path[Math.min(path.length, maxDepth) - 1] ?? null;
    }
    const next = parentId ? mapTree(list, parentId, parent => ({ ...parent, replies: [...(parent.replies ?? []), comment] })) : [...list, comment];
    if (parentId) setUi(current => ({ ...current, collapsed: { ...current.collapsed, [parentId!]: false } }));
    commit(next, { type: "reply", comment, parentId });
    setReplyTo(null);
  };

  const edit = (id: string, body: string) => {
    commit(mapTree(list, id, comment => ({ ...comment, body, edited: true })), { type: "edit", id, body });
    setUi(current => ({ ...current, editing: null }));
  };

  const remove = (id: string) => {
    playUiSound("confirm");
    commit(mapTree(list, id, comment => comment.replies?.length ? { ...comment, deleted: true, body: "", reactions: [] } : null), { type: "delete", id });
    setUi(current => ({ ...current, confirming: null }));
    if (replyTo?.id === id) setReplyTo(null);
  };

  const react = (id: string, emoji: string) => {
    const target = find(list, id);
    if (!target) return;
    playUiSound("tick");
    const existing = target.reactions?.find(reaction => reaction.emoji === emoji);
    const added = !existing?.users.includes(currentUser.id);
    const nextReactions = existing
      ? (target.reactions ?? []).map(reaction => reaction.emoji !== emoji ? reaction : { ...reaction, users: added ? [...reaction.users, currentUser.id] : reaction.users.filter(user => user !== currentUser.id) }).filter(reaction => reaction.users.length)
      : [...(target.reactions ?? []), { emoji, users: [currentUser.id] }];
    commit(mapTree(list, id, comment => ({ ...comment, reactions: nextReactions })), { type: "react", id, emoji, added });
    setUi(current => ({ ...current, picker: null }));
  };

  const grow = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.instant } }
    : { initial: { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: { height: 0, opacity: 0 }, transition: { height: HEIGHT, opacity: { duration: motionTokens.duration.standard, ease: enter } } };

  const swap = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }, transition: { duration: reduced ? 0 : motionTokens.duration.fast, ease: enter } };

  const renderComment = (comment: ThreadComment, depth: number): ReactNode => {
    const mine = comment.author.id === currentUser.id;
    const replies = comment.replies ?? [];
    const collapsed = !!ui.collapsed[comment.id];
    const editing = ui.editing === comment.id, confirming = ui.confirming === comment.id, picking = ui.picker === comment.id;
    return <motion.li key={comment.id} className={styles.item} {...grow}>
      <article className={styles.comment} aria-label={comment.deleted ? t.deletedComment : `${comment.author.name}, ${comment.createdAt}`}>
        {comment.deleted ? <span className={styles.deletedDot} aria-hidden="true" /> : <Avatar author={comment.author} />}
        <div className={styles.main}>
          {comment.deleted
            ? <p className={styles.deleted}>{t.deletedText}</p>
            : <>
              <header className={styles.meta}>
                <span className={styles.name}>{comment.author.name}</span>
                <span className={styles.time}>{comment.createdAt}{comment.edited && ` · ${t.edited}`}</span>
              </header>
              {/* Editing swaps the body for a composer in place: the two cross-fade while the comment's height springs between them.
                  The picker and delete prompt can wrap onto a new line on narrow screens, so they spring the height too. */}
              <AutoHeight reduced={reduced} morphKey={editing ? "edit" : confirming ? "confirm" : picking ? "picker" : "view"}>
                <AnimatePresence mode="popLayout" initial={false}>
                {editing
                ? <motion.div key="edit" {...swap}><Composer people={mentionable} initial={comment.body} placeholder={t.editComment} submitLabel={t.save} cancelLabel={t.cancel} mentionsLabel={t.mentions} autoFocus compact onSubmit={body => edit(comment.id, body)} onCancel={() => setUi(current => ({ ...current, editing: null }))} /></motion.div>
                : <motion.div key="view" {...swap}>
                <Body text={comment.body} people={mentionable} />

              <div className={styles.footer}>
                {(comment.reactions ?? []).length > 0 && <div className={styles.reactions}>
                  <AnimatePresence initial={false} mode="popLayout">
                    {(comment.reactions ?? []).map(reaction => {
                      const pressed = reaction.users.includes(currentUser.id);
                      return <motion.button key={reaction.emoji} type="button" layout={!reduced} className={styles.reaction} aria-pressed={pressed}
                        aria-label={t.reactionCount(reaction.emoji, reaction.users.length, pressed)} onClick={() => react(comment.id, reaction.emoji)}
                        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .7 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .7 }}
                        transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>
                        <span aria-hidden="true">{reaction.emoji}</span><Count value={reaction.users.length} reduced={reduced} />
                      </motion.button>;
                    })}
                  </AnimatePresence>
                </div>}

                <AnimatePresence mode="wait" initial={false}>
                  {confirming
                    ? <motion.div key="confirm" className={styles.actions} data-open="" initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }} transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}
                      onKeyDown={event => { if (event.key === "Escape") { event.stopPropagation(); setUi(current => ({ ...current, confirming: null })); } }}>
                      <span className={styles.prompt}>{t.deleteConfirm}</span>
                      <button type="button" className={styles.action} onClick={() => setUi(current => ({ ...current, confirming: null }))}>{t.cancel}</button>
                      <button type="button" className={styles.action} data-tone="danger" autoFocus onClick={() => remove(comment.id)}>{t.delete}</button>
                    </motion.div>
                    : picking
                    ? <motion.div key="picker" role="group" aria-label={t.reactions} className={styles.actions} data-open=""
                      onKeyDown={event => { if (event.key === "Escape") { event.stopPropagation(); setUi(current => ({ ...current, picker: null })); } }}
                      initial={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }} transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}>
                      {reactions.map((emoji, index) => <motion.button key={emoji} type="button" className={styles.emoji} autoFocus={index === 0} aria-label={t.reactWith(emoji)}
                        aria-pressed={!!comment.reactions?.find(reaction => reaction.emoji === emoji)?.users.includes(currentUser.id)} onClick={() => react(comment.id, emoji)}
                        initial={reduced ? false : { opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} transition={reduced ? { duration: 0 } : { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item }}>{emoji}</motion.button>)}
                      <button type="button" className={styles.iconAction} aria-label={t.closeReactions} onClick={() => setUi(current => ({ ...current, picker: null }))}><UiIcon iconRole="close" size={14} strokeWidth={1.75} aria-hidden="true" /></button>
                    </motion.div>
                    : <motion.div key="actions" className={styles.actions} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduced ? 0 : motionTokens.duration.instant, ease: standard }}>
                      <button type="button" className={styles.iconAction} aria-label={t.addReaction} onClick={() => setUi(current => ({ ...current, picker: comment.id, confirming: null }))}>
                        <HugeiconsIcon icon={SmileIcon} size={15} strokeWidth={1.75} aria-hidden="true" />
                      </button>
                      <button type="button" className={styles.action} onClick={() => { playUiSound("click"); setReplyTo({ id: comment.id, name: comment.author.name }); setFocusKey(key => key + 1); }}>{t.reply}</button>
                      {mine && <button type="button" className={styles.action} onClick={() => setUi(current => ({ ...current, editing: comment.id, confirming: null, picker: null }))}>{t.edit}</button>}
                      {mine && <button type="button" className={styles.action} onClick={() => setUi(current => ({ ...current, confirming: comment.id, editing: null, picker: null }))}>{t.delete}</button>}
                    </motion.div>}
                </AnimatePresence>
              </div>
                </motion.div>}
                </AnimatePresence>
              </AutoHeight>
            </>}

          {replies.length > 0 && <button type="button" className={styles.toggle} aria-expanded={!collapsed}
            onClick={() => setUi(current => ({ ...current, collapsed: { ...current.collapsed, [comment.id]: !collapsed } }))}>
            <motion.span className={styles.toggleIcon} initial={false} animate={{ rotate: collapsed ? 0 : 90 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy} aria-hidden="true">
              <UiIcon iconRole="chevron-right" size={13} strokeWidth={1.75} />
            </motion.span>
            {collapsed ? t.showReplies(countTree(replies)) : t.hideReplies}
            {collapsed && <span className={styles.stack} aria-hidden="true">{[...authorsOf(replies).values()].slice(0, 3).map(author => <Avatar key={author.id} author={author} size={18} />)}</span>}
          </button>}
        </div>
      </article>

      <AnimatePresence initial={false}>
        {replies.length > 0 && !collapsed && <motion.div key="replies" className={styles.repliesWrap} {...grow}>
          <ul className={styles.replies} data-depth={depth + 1}>
            <AnimatePresence initial={false}>{replies.map(reply => renderComment(reply, depth + 1))}</AnimatePresence>
          </ul>
        </motion.div>}
      </AnimatePresence>
    </motion.li>;
  };

  const face = { initial: reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: reduced ? "blur(0px)" : `blur(${motionTokens.blur.subtle}px)` }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: enter } };

  return <section ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-resolved={isResolved || undefined} aria-label={t.thread}>
    <AutoHeight reduced={reduced} morphKey={isResolved ? "resolved" : "thread"}>
      <AnimatePresence mode="popLayout" initial={false}>
        {isResolved
          ? <motion.div key="resolved" className={styles.resolved} {...face}>
            <span className={styles.resolvedIcon} aria-hidden="true"><UiIcon iconRole="check" size={14} strokeWidth={2} /></span>
            <span className={styles.resolvedText}>
              <span className={styles.resolvedTitle}>{t.resolved}{title ? <> · <span className={styles.resolvedSubject}>{title}</span></> : null}</span>
              <span className={styles.resolvedMeta}>{t.commentCount(total)} · {t.peopleCount(participants.length)}</span>
            </span>
            <span className={styles.stack} aria-hidden="true">{participants.slice(0, 3).map(author => <Avatar key={author.id} author={author} size={22} />)}</span>
            <button type="button" className={styles.reopen} data-reopen onClick={() => { playUiSound("click"); setResolved(false); }}><UiIcon iconRole="reset" size={14} strokeWidth={1.75} aria-hidden="true" />{t.reopen}</button>
          </motion.div>
          : <motion.div key="thread" className={styles.thread} {...face}>
            <header className={styles.header}>
              <div className={styles.heading}>
                {title && <h3 className={styles.title}>{title}</h3>}
                <span className={styles.subtitle}>{t.commentCount(total)}</span>
              </div>
              <button type="button" className={styles.resolve} data-resolve disabled={!total} onClick={() => { playUiSound("success"); setResolved(true); }}><UiIcon iconRole="check" size={15} strokeWidth={1.75} aria-hidden="true" />{t.resolve}</button>
            </header>

            {list.length > 0
              ? <ul className={styles.list}><AnimatePresence initial={false}>{list.map(comment => renderComment(comment, 0))}</AnimatePresence></ul>
              : <p className={styles.empty}>{t.empty}</p>}

            <div className={styles.composerArea}>
              <AnimatePresence initial={false}>
                {replyTo && <motion.div key="replying" className={styles.replying} {...grow}>
                  <span className={styles.replyingInner}>
                    <HugeiconsIcon icon={CornerDownRightIcon} size={13} strokeWidth={1.75} aria-hidden="true" />
                    <span>{highlight(t.replyingTo(replyTo.name), replyTo.name, styles.replyingName)}</span>
                    <button type="button" className={styles.iconAction} aria-label={t.cancelReply} onClick={() => setReplyTo(null)}><UiIcon iconRole="close" size={14} strokeWidth={1.75} aria-hidden="true" /></button>
                  </span>
                </motion.div>}
              </AnimatePresence>
              <Composer people={mentionable} placeholder={placeholder} submitLabel={t.send} cancelLabel={t.cancel} mentionsLabel={t.mentions} focusKey={focusKey} onSubmit={addReply}
                onCancel={replyTo ? () => setReplyTo(null) : undefined} leading={<Avatar author={currentUser} />} />
            </div>
          </motion.div>}
      </AnimatePresence>
    </AutoHeight>
  </section>;
});

export default CommentThread;