Zum Inhalt springen
UI Kit
Accent

Kommentare

Threads mit Antworten, ein Layout mit dem Formular zuerst, Fragen mit Antworten und eine kompakte Übersicht. Neue Kommentare erscheinen an Ort und Stelle.

Eine von 3 Components in Blog

4 Variants

Variants

Thread

Antworten unter ihrem Kommentar, die Thread-Linie zeichnet sich. Liken mit kleinem Pop, direkt antworten, ohne die Seite zu verlassen.

Im Studio öffnen

Formular zuerst

Das Formular bleibt links, die Kommentare rechts sortieren nach neuesten oder beliebtesten. Eine angeheftete Notiz bleibt oben.

Im Studio öffnen

Fragen und Antworten

Fragen mit Stimmen, die akzeptierte Antwort grün markiert. Nach beantwortet und offen filtern, direkt antworten.

Im Studio öffnen

Kompakt

Reaktionen und wer mitgemacht hat, die Kommentare öffnen auf Wunsch. Für das Ende eines Artikels.

Im Studio öffnen

Details

Was Kommentare mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Blog
Bereich
Content
Variants
4
Version
1.0.1 · seit 1.0.0
Datei
components/ui/comments.tsx
Bringt mit
utilskitbuttoniconmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 42 eigene Texte
In Sammlungen
Blog oder Magazin

Props

Die Schnittstelle CommentsProps aus components/ui/comments.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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.

Eingebaute Texte

Diese Texte schreibt Kommentare selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 42 Texte anzeigen
commentsEnglisch {n} commentsDeutsch {n} Kommentare
commentEnglisch 1 commentDeutsch 1 Kommentar
writeEnglisch Write a commentDeutsch Kommentar schreiben
writeQuestionEnglisch Ask a questionDeutsch Frage stellen
placeholderEnglisch What do you think?Deutsch Was denkst du?
placeholderQuestionEnglisch What would you like to know?Deutsch Was möchtest du wissen?
placeholderReplyEnglisch Reply to {name}Deutsch Antwort an {name}
postEnglisch Post commentDeutsch Kommentar senden
postQuestionEnglisch Post questionDeutsch Frage senden
replyEnglisch ReplyDeutsch Antworten
cancelEnglisch CancelDeutsch Abbrechen
postingEnglisch PostingDeutsch Wird gesendet
failedEnglisch That did not go through. Your text is still here, please try again.Deutsch Das hat nicht geklappt. Dein Text ist noch da, versuch es bitte noch einmal.
postedEnglisch PostedDeutsch Gesendet
likeEnglisch LikeDeutsch Gefällt mir
likedEnglisch LikedDeutsch Gefällt dir
likesEnglisch {n} likesDeutsch {n} Likes
hideRepliesEnglisch Hide repliesDeutsch Antworten ausblenden
showRepliesEnglisch Show {n} repliesDeutsch {n} Antworten zeigen
showReplyEnglisch Show 1 replyDeutsch 1 Antwort zeigen
authorEnglisch AuthorDeutsch Autor
pinnedEnglisch PinnedDeutsch Angeheftet
newestEnglisch NewestDeutsch Neueste
topEnglisch TopDeutsch Beliebteste
sortEnglisch Sort commentsDeutsch Kommentare sortieren
upvoteEnglisch Vote upDeutsch Hochstimmen
downvoteEnglisch Vote downDeutsch Runterstimmen
votesEnglisch {n} votesDeutsch {n} Stimmen
acceptedEnglisch Accepted answerDeutsch Akzeptierte Antwort
answersEnglisch {n} answersDeutsch {n} Antworten
answerEnglisch 1 answerDeutsch 1 Antwort
noAnswerEnglisch No answer yetDeutsch Noch keine Antwort
allEnglisch AllDeutsch Alle
answeredEnglisch AnsweredDeutsch Beantwortet
openEnglisch OpenDeutsch Offen
open_commentsEnglisch Show {n} commentsDeutsch {n} Kommentare zeigen
close_commentsEnglisch Hide commentsDeutsch Kommentare ausblenden
andEnglisch {a}, {b} and {n} othersDeutsch {a}, {b} und {n} weitere
youEnglisch YouDeutsch Du
nowEnglisch just nowDeutsch gerade eben
charsEnglisch {n} of {max}Deutsch {n} von {max}
reactionsEnglisch ReactionsDeutsch Reaktionen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kommentare reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Setzt aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, radio, radiogroup, tab und tablist.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 720 Zeilen
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} />;
}

Häufige Fragen

Wie installiere ich Kommentare?

Mit aiw add @ai-website/comments. Der Befehl legt die Datei components/ui/comments.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Kommentare?

4 Varianten, Thread, Formular zuerst, Fragen und Antworten und Kompakt. Du wählst sie mit der Prop variant, zum Beispiel variant="with-form".

Kann ich die Texte von Kommentare übersetzen?

Ja. Kommentare bringt 42 eigene Texte auf Deutsch und Englisch mit, etwa „1 Kommentar“, „Kommentar schreiben“ und „Frage stellen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kommentare einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kommentare mit aiw_components und installieren sie mit aiw_add @ai-website/comments. Nenn im Prompt die Variante, etwa variant="thread", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Kommentare ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Comments-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert