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
Variants
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 42 eigene Texte
- In Sammlungen
- Blog oder Magazin
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CommentsProps aus components/ui/comments.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCommentsVariantthreadwith-formq-and-acompact
titleoptionalstringdescriptionoptionalstringcommentsoptionalCommentItem[]meoptionalCommentAuthorThe visitor who writes, shown next to the form
reactionsoptional{ icon: IconName; label: string; count: number }[]compact: reactions with counts
peopleoptionalstring[]compact: names for the summary line
defaultOpenoptionalbooleancompact: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| comments | Englisch {n} comments | Deutsch {n} Kommentare |
| comment | Englisch 1 comment | Deutsch 1 Kommentar |
| write | Englisch Write a comment | Deutsch Kommentar schreiben |
| writeQuestion | Englisch Ask a question | Deutsch Frage stellen |
| placeholder | Englisch What do you think? | Deutsch Was denkst du? |
| placeholderQuestion | Englisch What would you like to know? | Deutsch Was möchtest du wissen? |
| placeholderReply | Englisch Reply to {name} | Deutsch Antwort an {name} |
| post | Englisch Post comment | Deutsch Kommentar senden |
| postQuestion | Englisch Post question | Deutsch Frage senden |
| reply | Englisch Reply | Deutsch Antworten |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| posting | Englisch Posting | Deutsch Wird gesendet |
| failed | Englisch 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. |
| posted | Englisch Posted | Deutsch Gesendet |
| like | Englisch Like | Deutsch Gefällt mir |
| liked | Englisch Liked | Deutsch Gefällt dir |
| likes | Englisch {n} likes | Deutsch {n} Likes |
| hideReplies | Englisch Hide replies | Deutsch Antworten ausblenden |
| showReplies | Englisch Show {n} replies | Deutsch {n} Antworten zeigen |
| showReply | Englisch Show 1 reply | Deutsch 1 Antwort zeigen |
| author | Englisch Author | Deutsch Autor |
| pinned | Englisch Pinned | Deutsch Angeheftet |
| newest | Englisch Newest | Deutsch Neueste |
| top | Englisch Top | Deutsch Beliebteste |
| sort | Englisch Sort comments | Deutsch Kommentare sortieren |
| upvote | Englisch Vote up | Deutsch Hochstimmen |
| downvote | Englisch Vote down | Deutsch Runterstimmen |
| votes | Englisch {n} votes | Deutsch {n} Stimmen |
| accepted | Englisch Accepted answer | Deutsch Akzeptierte Antwort |
| answers | Englisch {n} answers | Deutsch {n} Antworten |
| answer | Englisch 1 answer | Deutsch 1 Antwort |
| noAnswer | Englisch No answer yet | Deutsch Noch keine Antwort |
| all | Englisch All | Deutsch Alle |
| answered | Englisch Answered | Deutsch Beantwortet |
| open | Englisch Open | Deutsch Offen |
| open_comments | Englisch Show {n} comments | Deutsch {n} Kommentare zeigen |
| close_comments | Englisch Hide comments | Deutsch Kommentare ausblenden |
| and | Englisch {a}, {b} and {n} others | Deutsch {a}, {b} und {n} weitere |
| you | Englisch You | Deutsch Du |
| now | Englisch just now | Deutsch gerade eben |
| chars | Englisch {n} of {max} | Deutsch {n} von {max} |
| reactions | Englisch Reactions | Deutsch 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
'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.