Skip to content
UI Kit
Accent color

Comments

Threads with replies, a form first layout, questions with answers and a compact summary. New comments appear in place.

One of 3 components in Blog

4 variants

Variants

Thread

Replies under their comment, the thread line draws in. Like with a small pop, reply inline without leaving the page.

Open in Studio

Form first

The form stays on the left, the comments on the right sort by newest or most liked. A pinned note stays on top.

Open in Studio

Questions and answers

Questions with votes, the accepted answer marked green. Filter by answered and open, answer right in place.

Open in Studio

Compact

Reactions and who joined in, the comments open on demand. For the end of an article.

Open in Studio

Details

What Comments brings along, read from the registry and the source.

Category
Blog
Variants
4
Version
1.0.1 · since 1.0.0
File
components/ui/comments.tsx
Brings along
utilskitbuttoniconmotionsection
npm packages
None
Languages
English and German, 42 texts of its own
In collections
Blog or magazine

Props

The CommentsProps interface from components/ui/comments.tsx, with the comments from the code.

  • variantoptional
    CommentsVariant

    threadwith-formq-and-acompact

  • titleoptional
    string
  • descriptionoptional
    string
  • commentsoptional
    CommentItem[]
  • meoptional
    CommentAuthor

    The visitor who writes, shown next to the form

  • reactionsoptional
    { icon: IconName; label: string; count: number }[]

    compact: reactions with counts

  • peopleoptional
    string[]

    compact: names for the summary line

  • defaultOpenoptional
    boolean

    compact: show the comments on first render

  • onSubmitoptional
    (text: string, replyTo?: string) => void | Promise<void>

    Called with the text, and the id of the comment answered. Throw to show the error.

Built-in texts

Comments writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 42 texts
commentsEnglish {n} commentsGerman {n} Kommentare
commentEnglish 1 commentGerman 1 Kommentar
writeEnglish Write a commentGerman Kommentar schreiben
writeQuestionEnglish Ask a questionGerman Frage stellen
placeholderEnglish What do you think?German Was denkst du?
placeholderQuestionEnglish What would you like to know?German Was möchtest du wissen?
placeholderReplyEnglish Reply to {name}German Antwort an {name}
postEnglish Post commentGerman Kommentar senden
postQuestionEnglish Post questionGerman Frage senden
replyEnglish ReplyGerman Antworten
cancelEnglish CancelGerman Abbrechen
postingEnglish PostingGerman Wird gesendet
failedEnglish That did not go through. Your text is still here, please try again.German Das hat nicht geklappt. Dein Text ist noch da, versuch es bitte noch einmal.
postedEnglish PostedGerman Gesendet
likeEnglish LikeGerman Gefällt mir
likedEnglish LikedGerman Gefällt dir
likesEnglish {n} likesGerman {n} Likes
hideRepliesEnglish Hide repliesGerman Antworten ausblenden
showRepliesEnglish Show {n} repliesGerman {n} Antworten zeigen
showReplyEnglish Show 1 replyGerman 1 Antwort zeigen
authorEnglish AuthorGerman Autor
pinnedEnglish PinnedGerman Angeheftet
newestEnglish NewestGerman Neueste
topEnglish TopGerman Beliebteste
sortEnglish Sort commentsGerman Kommentare sortieren
upvoteEnglish Vote upGerman Hochstimmen
downvoteEnglish Vote downGerman Runterstimmen
votesEnglish {n} votesGerman {n} Stimmen
acceptedEnglish Accepted answerGerman Akzeptierte Antwort
answersEnglish {n} answersGerman {n} Antworten
answerEnglish 1 answerGerman 1 Antwort
noAnswerEnglish No answer yetGerman Noch keine Antwort
allEnglish AllGerman Alle
answeredEnglish AnsweredGerman Beantwortet
openEnglish OpenGerman Offen
open_commentsEnglish Show {n} commentsGerman {n} Kommentare zeigen
close_commentsEnglish Hide commentsGerman Kommentare ausblenden
andEnglish {a}, {b} and {n} othersGerman {a}, {b} und {n} weitere
youEnglish YouGerman Du
nowEnglish just nowGerman gerade eben
charsEnglish {n} of {max}German {n} von {max}
reactionsEnglish ReactionsGerman Reaktionen

Accessibility

Read from the source of the component.

  • Comments handles the keys Escape, Enter and Space itself.
  • Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles alert, radio, radiogroup, tab and tablist.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 720 lines
components/ui/comments.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Stagger } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Section } from '@/components/ui/section';

/*
 * Comments under an article, a recipe or a product: threads with replies,
 * a form that comes first, questions with answers and a compact summary
 * that opens the discussion. New comments appear in place. Sending goes
 * through `onSubmit`, the block keeps the new entry until the page reloads.
 *
 *   thread     replies indented under their comment, likes and inline reply
 *   with-form  the form on top, then the comments, sorted by newest or top
 *   q-and-a    questions with votes, the accepted answer marked
 *   compact    reactions and a short summary, the comments open on demand
 */

export type CommentsVariant = 'thread' | 'with-form' | 'q-and-a' | 'compact';

export interface CommentAuthor {
  name: string;
  /** A short line next to the name, e.g. Author or Head chef */
  role?: string;
  /** Marks the author of the page */
  isAuthor?: boolean;
  /** A photo, otherwise the initials */
  avatar?: React.ReactNode;
}

export interface CommentItem {
  id: string;
  author: CommentAuthor;
  /** As shown, e.g. 2 days ago. Written by you, so server and browser agree. */
  when: string;
  /** ISO date for the <time> element */
  date?: string;
  text: string;
  likes?: number;
  /** q-and-a: votes of a question or answer */
  votes?: number;
  /** q-and-a: the accepted answer */
  accepted?: boolean;
  /** Pinned to the top by the author */
  pinned?: boolean;
  replies?: CommentItem[];
}

export interface CommentsProps {
  variant?: CommentsVariant;
  title?: string;
  description?: string;
  comments?: CommentItem[];
  /** The visitor who writes, shown next to the form */
  me?: CommentAuthor;
  /** compact: reactions with counts */
  reactions?: { icon: IconName; label: string; count: number }[];
  /** compact: names for the summary line */
  people?: string[];
  /** compact: show the comments on first render */
  defaultOpen?: boolean;
  /** Called with the text, and the id of the comment answered. Throw to show the error. */
  onSubmit?: (text: string, replyTo?: string) => void | Promise<void>;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    comments: '{n} comments',
    comment: '1 comment',
    write: 'Write a comment',
    writeQuestion: 'Ask a question',
    placeholder: 'What do you think?',
    placeholderQuestion: 'What would you like to know?',
    placeholderReply: 'Reply to {name}',
    post: 'Post comment',
    postQuestion: 'Post question',
    reply: 'Reply',
    cancel: 'Cancel',
    posting: 'Posting',
    failed: 'That did not go through. Your text is still here, please try again.',
    posted: 'Posted',
    like: 'Like',
    liked: 'Liked',
    likes: '{n} likes',
    hideReplies: 'Hide replies',
    showReplies: 'Show {n} replies',
    showReply: 'Show 1 reply',
    author: 'Author',
    pinned: 'Pinned',
    newest: 'Newest',
    top: 'Top',
    sort: 'Sort comments',
    upvote: 'Vote up',
    downvote: 'Vote down',
    votes: '{n} votes',
    accepted: 'Accepted answer',
    answers: '{n} answers',
    answer: '1 answer',
    noAnswer: 'No answer yet',
    all: 'All',
    answered: 'Answered',
    open: 'Open',
    open_comments: 'Show {n} comments',
    close_comments: 'Hide comments',
    and: '{a}, {b} and {n} others',
    you: 'You',
    now: 'just now',
    chars: '{n} of {max}',
    reactions: 'Reactions',
  },
  de: {
    comments: '{n} Kommentare',
    comment: '1 Kommentar',
    write: 'Kommentar schreiben',
    writeQuestion: 'Frage stellen',
    placeholder: 'Was denkst du?',
    placeholderQuestion: 'Was möchtest du wissen?',
    placeholderReply: 'Antwort an {name}',
    post: 'Kommentar senden',
    postQuestion: 'Frage senden',
    reply: 'Antworten',
    cancel: 'Abbrechen',
    posting: 'Wird gesendet',
    failed: 'Das hat nicht geklappt. Dein Text ist noch da, versuch es bitte noch einmal.',
    posted: 'Gesendet',
    like: 'Gefällt mir',
    liked: 'Gefällt dir',
    likes: '{n} Likes',
    hideReplies: 'Antworten ausblenden',
    showReplies: '{n} Antworten zeigen',
    showReply: '1 Antwort zeigen',
    author: 'Autor',
    pinned: 'Angeheftet',
    newest: 'Neueste',
    top: 'Beliebteste',
    sort: 'Kommentare sortieren',
    upvote: 'Hochstimmen',
    downvote: 'Runterstimmen',
    votes: '{n} Stimmen',
    accepted: 'Akzeptierte Antwort',
    answers: '{n} Antworten',
    answer: '1 Antwort',
    noAnswer: 'Noch keine Antwort',
    all: 'Alle',
    answered: 'Beantwortet',
    open: 'Offen',
    open_comments: '{n} Kommentare zeigen',
    close_comments: 'Kommentare ausblenden',
    and: '{a}, {b} und {n} weitere',
    you: 'Du',
    now: 'gerade eben',
    chars: '{n} von {max}',
    reactions: 'Reaktionen',
  },
};

type Texts = (typeof TEXTS)['en'];
const MAX = 1000;
const fmt = (n: number, locale: Locale) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
const countAll = (list: CommentItem[]): number => list.reduce((n, c) => n + 1 + countAll(c.replies ?? []), 0);

/* ------------------------------------------------------------------ */
/* Small parts                                                         */
/* ------------------------------------------------------------------ */

function Avatar({ author, size = 'size-9' }: { author: CommentAuthor; size?: string }) {
  const initials = author.name
    .split(/\s+/)
    .map((w) => w[0])
    .slice(0, 2)
    .join('')
    .toUpperCase();
  return (
    <span data-clip-ok className={cn('relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-[12px] font-semibold text-muted-foreground ring-1 ring-border [&>*]:size-full', size, author.isAuthor && 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground ring-[color-mix(in_oklab,var(--primary)_35%,var(--border))]')}>
      {author.avatar ?? initials}
    </span>
  );
}

function Byline({ c, t }: { c: CommentItem; t: Texts }) {
  return (
    <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px]">
      <span className="font-semibold text-foreground">{c.author.name}</span>
      {c.author.isAuthor && <span className="rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] px-2 py-px text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{t.author}</span>}
      {c.author.role && <span className="text-muted-foreground">{c.author.role}</span>}
      <span aria-hidden className="text-subtle-foreground">·</span>
      <time dateTime={c.date} className="text-muted-foreground">
        {c.when}
      </time>
      {c.pinned && (
        <span className="inline-flex items-center gap-1 text-muted-foreground">
          <Icon name="pin" size={12} />
          {t.pinned}
        </span>
      )}
    </p>
  );
}

function LikeButton({ count = 0, t }: { count?: number; t: Texts }) {
  const locale = useLocale();
  const [on, setOn] = useState(false);
  const n = count + (on ? 1 : 0);
  return (
    <button type="button" aria-pressed={on} aria-label={`${on ? t.liked : t.like}, ${t.likes.replace('{n}', fmt(n, locale))}`} onClick={() => setOn((v) => !v)} className={cn('group/like inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium transition-colors hover:bg-accent max-sm:h-11', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
      <span className="relative grid size-4 place-items-center">
        <Icon name="heart" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)] group-active/like:scale-75', on && 'fill-[var(--destructive)] text-destructive motion-safe:animate-[kitfx-cm-pop_.45s_var(--ease-kit)]')} />
      </span>
      <span className="relative grid overflow-hidden tabular-nums">
        <span key={n} className="kit-rise">
          {fmt(n, locale)}
        </span>
      </span>
    </button>
  );
}

const CSS = '@keyframes kitfx-cm-pop{40%{transform:scale(1.35)}}@keyframes kitfx-cm-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}';

/* ------------------------------------------------------------------ */
/* Composer                                                            */
/* ------------------------------------------------------------------ */

function Composer({ me, placeholder, submit, onDone, onCancel, autoFocus, t, compact }: { me?: CommentAuthor; placeholder: string; submit: string; onDone: (text: string) => void | Promise<void>; onCancel?: () => void; autoFocus?: boolean; t: Texts; compact?: boolean }) {
  const [text, setText] = useState('');
  const [state, setState] = useState<'idle' | 'sending' | 'failed'>('idle');
  const id = useId();
  const ref = useRef<HTMLTextAreaElement>(null);
  const locale = useLocale();
  useEffect(() => {
    if (autoFocus) ref.current?.focus();
  }, [autoFocus]);
  // Grows with the text, no inner scrollbar up to 12 lines
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = `${Math.min(el.scrollHeight, 320)}px`;
  }, [text]);
  const send = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;
    setState('sending');
    try {
      await onDone(text.trim());
      setText('');
      setState('idle');
    } catch {
      setState('failed');
    }
  };
  return (
    <form onSubmit={send} className="flex gap-3">
      {me && !compact && <Avatar author={me} />}
      <div className="min-w-0 flex-1">
        <label htmlFor={id} className="sr-only">
          {placeholder}
        </label>
        <div className="rounded-xl border border-input bg-card shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[0_0_0_4px_var(--glow)]">
          <textarea
            ref={ref}
            id={id}
            rows={compact ? 2 : 3}
            maxLength={MAX}
            value={text}
            onChange={(e) => setText(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send(e);
              if (e.key === 'Escape' && onCancel) onCancel();
            }}
            placeholder={placeholder}
            aria-invalid={state === 'failed' || undefined}
            aria-describedby={state === 'failed' ? `${id}-error` : undefined}
            className="block w-full resize-none bg-transparent px-4 pt-3 text-[15px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus-visible:outline-none"
          />
          <div className="flex items-center justify-between gap-3 px-2 pb-2 pl-4">
            <span className={cn('text-[12px] tabular-nums transition-opacity duration-300', text.length > MAX * 0.8 ? 'text-warning-foreground' : 'text-subtle-foreground', text ? 'opacity-100' : 'opacity-0')}>
              {t.chars.replace('{n}', fmt(text.length, locale)).replace('{max}', fmt(MAX, locale))}
            </span>
            <span className="flex gap-2">
              {onCancel && (
                <Button type="button" variant="ghost" size="sm" onClick={onCancel}>
                  {t.cancel}
                </Button>
              )}
              <Button type="submit" size="sm" disabled={!text.trim() || state === 'sending'}>
                {state === 'sending' && <Icon name="loader" size={15} className="animate-kit-spin [--kit-duration:0.8s]" />}
                {state === 'sending' ? t.posting : submit}
              </Button>
            </span>
          </div>
        </div>
        {state === 'failed' && (
          <p id={`${id}-error`} role="alert" className="kit-rise mt-2 text-[13px] text-destructive">
            {t.failed}
          </p>
        )}
      </div>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* thread                                                              */
/* ------------------------------------------------------------------ */

function useList(initial: CommentItem[] | undefined, me: CommentAuthor | undefined, t: Texts, onSubmit?: CommentsProps['onSubmit']) {
  const [list, setList] = useState<CommentItem[]>(initial ?? []);
  const [fresh, setFresh] = useState<string | null>(null);
  useEffect(() => setList(initial ?? []), [initial]);
  const add = async (text: string, replyTo?: string) => {
    await onSubmit?.(text, replyTo);
    const item: CommentItem = { id: `new-${Date.now()}`, author: me ?? { name: t.you }, when: t.now, text, likes: 0 };
    const insert = (items: CommentItem[]): CommentItem[] => items.map((c) => (c.id === replyTo ? { ...c, replies: [...(c.replies ?? []), item] } : { ...c, replies: c.replies && insert(c.replies) }));
    setList((l) => (replyTo ? insert(l) : [item, ...l]));
    setFresh(item.id);
  };
  return { list, add, fresh };
}

function CommentView({ c, depth, t, me, add, fresh }: { c: CommentItem; depth: number; t: Texts; me?: CommentAuthor; add: (text: string, replyTo?: string) => Promise<void>; fresh: string | null }) {
  const [replying, setReplying] = useState(false);
  const [open, setOpen] = useState(true);
  const replies = c.replies ?? [];
  const [lineRef, lineIn] = useInView<HTMLDivElement>();
  const listId = useId();
  return (
    <li className={cn('relative', c.id === fresh && 'motion-safe:animate-[kitfx-cm-flash_1.6s_var(--ease-kit)] rounded-xl')}>
      <article className="flex gap-3 sm:gap-4">
        <Avatar author={c.author} size={depth ? 'size-8' : 'size-9 sm:size-10'} />
        <div className="min-w-0 flex-1">
          <Byline c={c} t={t} />
          <p className="mt-1.5 whitespace-pre-line text-pretty text-[15px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]">{c.text}</p>
          <div className="-ml-2 mt-1.5 flex flex-wrap items-center gap-1">
            <LikeButton count={c.likes} t={t} />
            {depth < 2 && (
              <button type="button" aria-expanded={replying} onClick={() => setReplying((v) => !v)} className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11">
                <Icon name="reply" size={15} />
                {t.reply}
              </button>
            )}
            {replies.length > 0 && (
              <button type="button" aria-expanded={open} aria-controls={listId} onClick={() => setOpen((v) => !v)} className="inline-flex h-9 items-center gap-1 rounded-md px-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:h-11">
                <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', !open && '-rotate-90')} />
                {open ? t.hideReplies : replies.length === 1 ? t.showReply : t.showReplies.replace('{n}', String(replies.length))}
              </button>
            )}
          </div>
          <div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', replying ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
            <div className="min-h-0 overflow-hidden" inert={!replying}>
              <div className="pb-1 pt-3">
                {replying && (
                  <Composer
                    compact
                    autoFocus
                    t={t}
                    placeholder={t.placeholderReply.replace('{name}', c.author.name)}
                    submit={t.reply}
                    onCancel={() => setReplying(false)}
                    onDone={async (text) => {
                      await add(text, c.id);
                      setReplying(false);
                      setOpen(true);
                    }}
                  />
                )}
              </div>
            </div>
          </div>
          {replies.length > 0 && (
            <div id={listId} ref={lineRef} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
              <div className="min-h-0 overflow-hidden" inert={!open}>
                <div className="relative mt-4 pl-4 sm:pl-5">
                  {/* The thread line draws down when the replies come into view */}
                  <span aria-hidden className={cn('absolute bottom-2 left-0 top-0 w-px origin-top bg-border-strong transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', lineIn ? 'scale-y-100' : 'scale-y-0')} />
                  <ul className="space-y-5">
                    {replies.map((r) => (
                      <CommentView key={r.id} c={r} depth={depth + 1} t={t} me={me} add={add} fresh={fresh} />
                    ))}
                  </ul>
                </div>
              </div>
            </div>
          )}
        </div>
      </article>
    </li>
  );
}

function Heading({ title, count, description, t, children }: { title?: string; count: number; description?: string; t: Texts; children?: React.ReactNode }) {
  const locale = useLocale();
  return (
    <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div>
        <h2 className="flex items-baseline gap-3 text-h3 font-semibold text-foreground">
          {title ?? (count === 1 ? t.comment : t.comments.replace('{n}', fmt(count, locale)))}
          {title && (
            <span key={count} className="kit-rise text-[15px] font-medium tabular-nums text-muted-foreground">
              {fmt(count, locale)}
            </span>
          )}
        </h2>
        {description && <p className="mt-1.5 max-w-[34rem] text-pretty text-[14.5px] text-muted-foreground">{description}</p>}
      </div>
      {children}
    </div>
  );
}

function ThreadVariant(p: CommentsProps & { t: Texts }) {
  const { t } = p;
  const { list, add, fresh } = useList(p.comments, p.me, t, p.onSubmit);
  return (
    <Section spacing="sm" className={p.className}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div className="mx-auto max-w-[46rem]">
        <Heading title={p.title} description={p.description} count={countAll(list)} t={t} />
        <Stagger as="ul" step={80} className="space-y-8">
          {list.map((c) => (
            <CommentView key={c.id} c={c} depth={0} t={t} me={p.me} add={add} fresh={fresh} />
          ))}
        </Stagger>
        <div className="mt-10 border-t border-border pt-8">
          <Composer me={p.me} t={t} placeholder={t.placeholder} submit={t.post} onDone={(text) => add(text)} />
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* with-form                                                           */
/* ------------------------------------------------------------------ */

function SortToggle({ value, onChange, t }: { value: 'newest' | 'top'; onChange: (v: 'newest' | 'top') => void; t: Texts }) {
  return (
    <div role="radiogroup" aria-label={t.sort} className="relative inline-grid h-10 shrink-0 grid-cols-2 self-start rounded-lg border border-border bg-surface p-1 max-sm:h-11 sm:self-auto">
      <span aria-hidden className={cn('absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-md bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', value === 'top' && 'translate-x-full')} />
      {(['newest', 'top'] as const).map((v) => (
        <button key={v} type="button" role="radio" aria-checked={value === v} onClick={() => onChange(v)} className={cn('relative z-10 min-w-24 rounded-md px-3 text-[13.5px] font-medium transition-colors', value === v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
          {t[v]}
        </button>
      ))}
    </div>
  );
}

function WithFormVariant(p: CommentsProps & { t: Texts }) {
  const { t } = p;
  const { list, add, fresh } = useList(p.comments, p.me, t, p.onSubmit);
  const [sort, setSort] = useState<'newest' | 'top'>('newest');
  const sorted = useMemo(() => {
    const pinned = list.filter((c) => c.pinned);
    const rest = list.filter((c) => !c.pinned);
    return [...pinned, ...(sort === 'top' ? [...rest].sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0)) : rest)];
  }, [list, sort]);
  return (
    <Section spacing="sm" className={p.className}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
        <div className="lg:sticky lg:top-24 lg:self-start">
          <Heading title={p.title} description={p.description} count={countAll(list)} t={t} />
          <div className="-mt-2 rounded-2xl border border-border bg-surface p-4 shadow-[var(--highlight)] sm:p-5">
            <p className="mb-3 text-[14px] font-semibold text-foreground">{t.write}</p>
            <Composer me={p.me} t={t} placeholder={t.placeholder} submit={t.post} onDone={(text) => add(text)} />
          </div>
        </div>
        <div className="min-w-0">
          <div className="mb-6 flex items-center justify-between gap-3 border-b border-border pb-4">
            <p className="text-[14px] font-medium text-foreground">{countAll(list) === 1 ? t.comment : t.comments.replace('{n}', String(countAll(list)))}</p>
            <SortToggle value={sort} onChange={setSort} t={t} />
          </div>
          <Stagger key={sort} as="ul" step={60} className="divide-y divide-border">
            {sorted.map((c) => (
              <li key={c.id} className={cn('py-5 first:pt-0', c.id === fresh && 'motion-safe:animate-[kitfx-cm-flash_1.6s_var(--ease-kit)]')}>
                <article className="flex gap-3 sm:gap-4">
                  <Avatar author={c.author} />
                  <div className="min-w-0 flex-1">
                    <Byline c={c} t={t} />
                    <p className="mt-1.5 text-pretty text-[15px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]">{c.text}</p>
                    <div className="-ml-2 mt-1">
                      <LikeButton count={c.likes} t={t} />
                    </div>
                  </div>
                </article>
              </li>
            ))}
          </Stagger>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* q-and-a                                                             */
/* ------------------------------------------------------------------ */

function Votes({ count = 0, t }: { count?: number; t: Texts }) {
  const locale = useLocale();
  const [v, setV] = useState<0 | 1 | -1>(0);
  const n = count + v;
  const btn = (dir: 1 | -1) => (
    <button
      type="button"
      aria-pressed={v === dir}
      aria-label={dir === 1 ? t.upvote : t.downvote}
      onClick={() => setV((x) => (x === dir ? 0 : dir))}
      className={cn('grid size-9 place-items-center rounded-lg transition-[background-color,color,transform] duration-200 active:scale-90 max-sm:size-10', v === dir ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}
    >
      <Icon name={dir === 1 ? 'chevron-up' : 'chevron-down'} size={18} strokeWidth={2} />
    </button>
  );
  return (
    <div className="flex shrink-0 flex-col items-center" aria-label={t.votes.replace('{n}', fmt(n, locale))}>
      {btn(1)}
      <span className="relative grid h-6 overflow-hidden text-[14px] font-semibold tabular-nums text-foreground">
        <span key={n} className="kit-rise">
          {fmt(n, locale)}
        </span>
      </span>
      {btn(-1)}
    </div>
  );
}

function QaVariant(p: CommentsProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const { list, add, fresh } = useList(p.comments, p.me, t, p.onSubmit);
  const [filter, setFilter] = useState<'all' | 'answered' | 'open'>('all');
  const [open, setOpen] = useState<string | null>(p.comments?.[0]?.id ?? null);
  const isAnswered = (c: CommentItem) => (c.replies ?? []).length > 0;
  const shown = list.filter((c) => filter === 'all' || (filter === 'answered' ? isAnswered(c) : !isAnswered(c)));
  return (
    <Section spacing="sm" className={p.className}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div className="mx-auto max-w-[52rem]">
        <Heading title={p.title} description={p.description} count={list.length} t={t}>
          <div role="tablist" className="flex gap-1 rounded-lg border border-border bg-surface p-1">
            {(['all', 'answered', 'open'] as const).map((f) => (
              <button key={f} type="button" role="tab" aria-selected={filter === f} onClick={() => setFilter(f)} className={cn('h-8 rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-300 max-sm:h-10 max-sm:flex-1', filter === f ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                {t[f]}
              </button>
            ))}
          </div>
        </Heading>
        <Stagger key={filter} as="ul" step={70} className="space-y-3">
          {shown.map((q) => {
            const answers = [...(q.replies ?? [])].sort((a, b) => Number(!!b.accepted) - Number(!!a.accepted));
            const expanded = open === q.id;
            const bodyId = `qa-${q.id}`;
            return (
              <li key={q.id} className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-shadow duration-300', expanded && 'shadow-[var(--highlight),var(--shadow-md)]', q.id === fresh && 'motion-safe:animate-[kitfx-cm-flash_1.6s_var(--ease-kit)]')}>
                <div className="flex gap-3 p-4 sm:gap-4 sm:p-5">
                  <Votes count={q.votes} t={t} />
                  <div className="min-w-0 flex-1 pt-1">
                    <button type="button" aria-expanded={expanded} aria-controls={bodyId} onClick={() => setOpen(expanded ? null : q.id)} className="group/q block w-full text-left">
                      <span className="flex items-start justify-between gap-3">
                        <span className="text-pretty text-[16px] font-semibold leading-snug text-foreground">{q.text}</span>
                        <Icon name="chevron-down" size={18} className={cn('mt-0.5 shrink-0 text-muted-foreground transition-transform duration-300 ease-[var(--ease-kit)]', expanded && 'rotate-180')} />
                      </span>
                      <span className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
                        <span>
                          {q.author.name} · {q.when}
                        </span>
                        <span className={cn('inline-flex items-center gap-1 font-medium', answers.length ? 'text-success-foreground' : 'text-muted-foreground')}>
                          <Icon name={answers.length ? 'check-circle' : 'clock'} size={14} />
                          {answers.length === 0 ? t.noAnswer : answers.length === 1 ? t.answer : t.answers.replace('{n}', fmt(answers.length, locale))}
                        </span>
                      </span>
                    </button>
                    <div id={bodyId} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                      <div className="min-h-0 overflow-hidden" inert={!expanded}>
                        <ul className="mt-4 space-y-3">
                          {answers.map((a) => (
                            <li key={a.id} className={cn('rounded-xl border p-4', a.accepted ? 'border-[color-mix(in_oklch,var(--success)_35%,var(--border))] bg-[color-mix(in_oklch,var(--success)_6%,var(--card))]' : 'border-border bg-surface', a.id === fresh && 'kit-rise')}>
                              {a.accepted && (
                                <p className="mb-2 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-success-foreground">
                                  <Icon name="check-circle" size={14} />
                                  {t.accepted}
                                </p>
                              )}
                              <div className="flex gap-3">
                                <Avatar author={a.author} size="size-8" />
                                <div className="min-w-0 flex-1">
                                  <Byline c={a} t={t} />
                                  <p className="mt-1 text-pretty text-[14.5px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]">{a.text}</p>
                                </div>
                              </div>
                            </li>
                          ))}
                        </ul>
                        <div className="mt-3">
                          <Composer compact t={t} placeholder={t.placeholderReply.replace('{name}', q.author.name)} submit={t.reply} onDone={(text) => add(text, q.id)} />
                        </div>
                      </div>
                    </div>
                  </div>
                </div>
              </li>
            );
          })}
        </Stagger>
        <div className="mt-8 rounded-2xl border border-dashed border-border-strong p-4 sm:p-5">
          <p className="mb-3 text-[14px] font-semibold text-foreground">{t.writeQuestion}</p>
          <Composer me={p.me} t={t} placeholder={t.placeholderQuestion} submit={t.postQuestion} onDone={(text) => add(text)} />
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* compact                                                             */
/* ------------------------------------------------------------------ */

function CompactVariant(p: CommentsProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const { list, add, fresh } = useList(p.comments, p.me, t, p.onSubmit);
  const [open, setOpen] = useState(!!p.defaultOpen);
  const [mine, setMine] = useState<number | null>(null);
  const id = useId();
  const total = countAll(list);
  const people = p.people ?? [];
  const reactionsTotal = (p.reactions ?? []).reduce((n, r) => n + r.count, 0) + (mine !== null ? 1 : 0);
  return (
    <Section spacing="sm" className={p.className}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div className="mx-auto max-w-[46rem] rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
        <div className="flex flex-col gap-4 p-4 sm:p-5">
          <ul aria-label={t.reactions} className="flex flex-wrap gap-2">
            {(p.reactions ?? []).map((r, i) => {
              const on = mine === i;
              return (
                <li key={i}>
                  <button type="button" aria-pressed={on} onClick={() => setMine(on ? null : i)} className={cn('group/re inline-flex h-10 items-center gap-2 rounded-full border px-3.5 text-[13.5px] font-medium transition-[background-color,border-color,color,transform] duration-300 ease-[var(--ease-kit)] active:scale-95 max-sm:h-11', on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground')}>
                    <Icon name={r.icon} size={16} className={cn('transition-transform duration-300 group-hover/re:-rotate-12 group-hover/re:scale-110', on && 'text-primary motion-safe:animate-[kitfx-cm-pop_.45s_var(--ease-kit)]')} />
                    <span>{r.label}</span>
                    <span className="relative grid overflow-hidden tabular-nums">
                      <span key={r.count + (on ? 1 : 0)} className="kit-rise">
                        {fmt(r.count + (on ? 1 : 0), locale)}
                      </span>
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
          {people.length >= 2 && (
            <p className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
              {/* A light overlap with smaller initials, so every pair of letters stays whole */}
              <span className="flex -space-x-1">
                {people.slice(0, 3).map((n) => (
                  <Avatar key={n} author={{ name: n }} size="size-7 text-[11px] ring-2 ring-card" />
                ))}
              </span>
              {t.and.replace('{a}', people[0]).replace('{b}', people[1]).replace('{n}', fmt(Math.max(0, reactionsTotal - 2), locale))}
            </p>
          )}
        </div>
        <button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((v) => !v)} className="flex min-h-14 w-full items-center justify-between gap-3 border-t border-border px-4 text-left text-[14.5px] font-medium text-foreground transition-colors hover:bg-surface sm:px-5">
          <span className="flex items-center gap-2.5">
            <Icon name="chats" size={18} className="text-muted-foreground" />
            {open ? t.close_comments : t.open_comments.replace('{n}', fmt(total, locale))}
          </span>
          <Icon name="chevron-down" size={17} className={cn('text-muted-foreground transition-transform duration-300 ease-[var(--ease-kit)]', open && 'rotate-180')} />
        </button>
        <div id={id} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
          <div className="min-h-0 overflow-hidden" inert={!open}>
            <div className="border-t border-border p-4 sm:p-5">
              <ul className="space-y-5">
                {list.map((c, i) => (
                  <li key={c.id} className={cn('flex gap-3 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)]', open ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0', c.id === fresh && 'motion-safe:animate-[kitfx-cm-flash_1.6s_var(--ease-kit)] rounded-lg')} style={{ transitionDelay: open ? `${100 + i * 60}ms` : '0ms' }}>
                    <Avatar author={c.author} size="size-8" />
                    <div className="min-w-0 flex-1">
                      <Byline c={c} t={t} />
                      <p className="mt-1 text-pretty text-[14.5px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]">{c.text}</p>
                    </div>
                  </li>
                ))}
              </ul>
              <div className="mt-6">
                <Composer compact t={t} placeholder={t.placeholder} submit={t.post} onDone={(text) => add(text)} />
              </div>
            </div>
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* Comments                                                            */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<CommentsVariant, (p: CommentsProps & { t: Texts }) => React.ReactNode> = {
  thread: ThreadVariant,
  'with-form': WithFormVariant,
  'q-and-a': QaVariant,
  compact: CompactVariant,
};

/**
 * Comments, replies, questions and answers.
 *   <Comments variant="thread" comments={[{ id, author: { name }, when: '2 days ago', text, replies }]} onSubmit={async (text, replyTo) => …} />
 */
export function Comments({ variant = 'thread', labels, ...props }: CommentsProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? ThreadVariant;
  return <View {...props} t={t} />;
}

Frequent questions

How do I install Comments?

With aiw add @ai-website/comments. The command puts components/ui/comments.tsx into your project, the code is yours from then on. It brings along button, icon, motion and section. It needs no further npm packages.

Which variants does Comments have?

4 variants, Thread, Form first, Questions and answers and Compact. You pick one with the variant prop, for example variant="with-form".

Can I translate the texts of Comments?

Yes. Comments carries 42 texts of its own in English and German, such as “1 comment”, “Write a comment” and “Ask a question”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Comments?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Comments with aiw_components and install it with aiw_add @ai-website/comments. Name the variant in your prompt, for example variant="thread", and the assistant writes your content straight into the props.

Can I adjust Comments without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching Comments code to copy.

Often searched

Jump to

Copied to the clipboard