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
Details
What Comments brings along, read from the registry and the source.
- Category
- Blog
- Area
- Content
- Variants
- 4
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/comments.tsx
- npm packages
- None
- Languages
- English and German, 42 texts of its own
- Studio
- All 4 variants editable
- In collections
- Blog or magazine
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CommentsProps interface from components/ui/comments.tsx, with the comments from the code.
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.
Built-in texts
Comments writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 42 texts
| Key | English | German |
|---|---|---|
| comments | English {n} comments | German {n} Kommentare |
| comment | English 1 comment | German 1 Kommentar |
| write | English Write a comment | German Kommentar schreiben |
| writeQuestion | English Ask a question | German Frage stellen |
| placeholder | English What do you think? | German Was denkst du? |
| placeholderQuestion | English What would you like to know? | German Was möchtest du wissen? |
| placeholderReply | English Reply to {name} | German Antwort an {name} |
| post | English Post comment | German Kommentar senden |
| postQuestion | English Post question | German Frage senden |
| reply | English Reply | German Antworten |
| cancel | English Cancel | German Abbrechen |
| posting | English Posting | German Wird gesendet |
| failed | English 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. |
| posted | English Posted | German Gesendet |
| like | English Like | German Gefällt mir |
| liked | English Liked | German Gefällt dir |
| likes | English {n} likes | German {n} Likes |
| hideReplies | English Hide replies | German Antworten ausblenden |
| showReplies | English Show {n} replies | German {n} Antworten zeigen |
| showReply | English Show 1 reply | German 1 Antwort zeigen |
| author | English Author | German Autor |
| pinned | English Pinned | German Angeheftet |
| newest | English Newest | German Neueste |
| top | English Top | German Beliebteste |
| sort | English Sort comments | German Kommentare sortieren |
| upvote | English Vote up | German Hochstimmen |
| downvote | English Vote down | German Runterstimmen |
| votes | English {n} votes | German {n} Stimmen |
| accepted | English Accepted answer | German Akzeptierte Antwort |
| answers | English {n} answers | German {n} Antworten |
| answer | English 1 answer | German 1 Antwort |
| noAnswer | English No answer yet | German Noch keine Antwort |
| all | English All | German Alle |
| answered | English Answered | German Beantwortet |
| open | English Open | German Offen |
| open_comments | English Show {n} comments | German {n} Kommentare zeigen |
| close_comments | English Hide comments | German Kommentare ausblenden |
| and | English {a}, {b} and {n} others | German {a}, {b} und {n} weitere |
| you | English You | German Du |
| now | English just now | German gerade eben |
| chars | English {n} of {max} | German {n} von {max} |
| reactions | English Reactions | German 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
'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.