Skip to content
UI Kit
Accent color

Answer feedback

Feedback on AI answers in five forms. Thumbs with reasons and a comment, a correction with preview and source, ratings on several scales, a report dialog with a privacy choice and an insights view for the team.

One of 4 components in AI answers

5 variants

Variants

Thumbs with reasons

Thumbs up or down under the answer. A rating opens reasons that fit it and an optional comment, the note says who reads it.

Open in Studio

Suggest a correction

The wrong sentence is marked in the answer, the correction shows as a before and after preview and can carry a source.

Open in Studio

Rating scales

Several scales from 1 to 5 with words at both ends, arrow keys move, the overall value updates as you rate.

Open in Studio

Report dialog

A dialog over the answer with the kind of problem, details and a choice to include the conversation. Focus stays inside, Escape closes.

Open in Studio

Feedback insights

An overview for the team with the helpful share with its trend, the most named problems as bars and the latest comments with filter.

Open in Studio

Details

What Answer feedback brings along, read from the registry and the source.

Category
AI answers
Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/feedback-on-answer.tsx
Brings along
utilskiticonrevealmotion
npm packages
None
Languages
English and German, 42 texts of its own

Props

The FeedbackOnAnswerProps interface from components/ui/feedback-on-answer.tsx, with the comments from the code.

  • variantoptional
    FeedbackOnAnswerVariant

    thumbscorrectionrating-scalereport-dialoginsights

  • titleoptional
    string
  • answeroptional
    { question: string; text: string; assistant?: string }

    The answer the feedback is about

  • defaultRatingoptional
    'up' | 'down'

    thumbs: a rating that is already given, the reasons open

  • reasonsoptional
    FeedbackReason[]

    thumbs: reasons for a bad answer and for a good one

  • praiseoptional
    FeedbackReason[]
  • wrongoptional
    string

    correction: the sentence to correct

  • correctionoptional
    string

    correction: the corrected sentence already typed in

  • defaultValuesoptional
    Record<string, number>

    rating-scale: values already chosen, by dimension id

  • dimensionsoptional
    FeedbackDimension[]

    rating-scale

  • defaultOpenoptional
    boolean

    report-dialog: start open

  • periodoptional
    string

    insights

  • positiveoptional
    number
  • negativeoptional
    number
  • trendoptional
    number[]

    insights: share of good ratings per week, 0 to 1

  • topReasonsoptional
    { label: string; count: number }[]
  • commentsoptional
    FeedbackComment[]
  • onSubmitoptional
    (value: Record<string, unknown>) => void

Built-in texts

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

Show all 42 texts
helpfulEnglish Was this answer helpful?German War diese Antwort hilfreich?
goodEnglish Good answerGerman Gute Antwort
badEnglish Bad answerGerman Schlechte Antwort
whatWrongEnglish What was the problem?German Was war das Problem?
whatGoodEnglish What was good?German Was war gut?
commentEnglish Add details (optional)German Details ergänzen (optional)
commentPlaceholderEnglish What should the answer have said?German Was hätte die Antwort sagen sollen?
sendEnglish Send feedbackGerman Feedback senden
thanksEnglish Thanks. Your feedback helps improve the answers.German Danke. Dein Feedback hilft, die Antworten zu verbessern.
changeEnglish ChangeGerman Ändern
privacyEnglish Your rating and this conversation are reviewed by the product team. They are never shared outside it.German Deine Bewertung und dieses Gespräch sieht nur das Produktteam. Nichts davon geht nach außen.
suggestEnglish Suggest a correctionGerman Korrektur vorschlagen
yourVersionEnglish Your correctionGerman Deine Korrektur
sourceEnglish Source that shows it (optional)German Quelle, die es belegt (optional)
sourcePlaceholderEnglish https://German https://
previewEnglish PreviewGerman Vorschau
submitEnglish Send correctionGerman Korrektur senden
correctedEnglish Correction sent. The team checks it before anything changes.German Korrektur gesendet. Das Team prüft sie, bevor sich etwas ändert.
cancelEnglish CancelGerman Abbrechen
rateEnglish Rate this answerGerman Diese Antwort bewerten
overallEnglish OverallGerman Gesamt
sendRatingEnglish Send ratingGerman Bewertung senden
ratedEnglish Rating saved.German Bewertung gespeichert.
ofEnglish {n} of 5German {n} von 5
reportEnglish Report a problemGerman Problem melden
reportTitleEnglish Report this answerGerman Diese Antwort melden
categoryEnglish What kind of problem?German Welche Art von Problem?
detailsEnglish DetailsGerman Details
detailsPlaceholderEnglish Describe what happenedGerman Beschreib, was passiert ist
includeEnglish Include this conversationGerman Dieses Gespräch mitsenden
includeNoteEnglish The team can read the messages to understand the problem.German Das Team kann die Nachrichten lesen, um das Problem zu verstehen.
reportedEnglish Report received. We answer within two working days.German Meldung erhalten. Wir antworten innerhalb von zwei Werktagen.
closeEnglish CloseGerman Schließen
requiredEnglish Choose a kind of problem first.German Wähl zuerst eine Art von Problem.
positiveEnglish HelpfulGerman Hilfreich
negativeEnglish Not helpfulGerman Nicht hilfreich
ratioEnglish Helpful shareGerman Anteil hilfreich
topReasonsEnglish Most named problemsGerman Häufigste Probleme
recentEnglish Latest commentsGerman Neueste Kommentare
allEnglish AllGerman Alle
weeksEnglish Per weekGerman Je Woche
questionEnglish QuestionGerman Frage

Accessibility

Read from the source of the component.

  • Answer feedback handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Space itself.
  • Sets aria-checked, aria-expanded, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-modal and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles alert, dialog, group, radio, radiogroup and status.
  • 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. 1 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, 797 lines
components/ui/feedback-on-answer.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type FeedbackOnAnswerVariant = 'thumbs' | 'correction' | 'rating-scale' | 'report-dialog' | 'insights';

export interface FeedbackReason {
  id: string;
  label: string;
}

export interface FeedbackDimension {
  id: string;
  label: string;
  /** Words at both ends of the scale */
  low: string;
  high: string;
}

export interface FeedbackComment {
  id: string;
  rating: 'up' | 'down';
  reason?: string;
  text: string;
  question: string;
  time: string;
}

export interface FeedbackOnAnswerProps {
  variant?: FeedbackOnAnswerVariant;
  title?: string;
  /** The answer the feedback is about */
  answer?: { question: string; text: string; assistant?: string };
  /** thumbs: a rating that is already given, the reasons open */
  defaultRating?: 'up' | 'down';
  /** thumbs: reasons for a bad answer and for a good one */
  reasons?: FeedbackReason[];
  praise?: FeedbackReason[];
  /** correction: the sentence to correct */
  wrong?: string;
  /** correction: the corrected sentence already typed in */
  correction?: string;
  /** rating-scale: values already chosen, by dimension id */
  defaultValues?: Record<string, number>;
  /** rating-scale */
  dimensions?: FeedbackDimension[];
  /** report-dialog: start open */
  defaultOpen?: boolean;
  /** insights */
  period?: string;
  positive?: number;
  negative?: number;
  /** insights: share of good ratings per week, 0 to 1 */
  trend?: number[];
  topReasons?: { label: string; count: number }[];
  comments?: FeedbackComment[];
  onSubmit?: (value: Record<string, unknown>) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    helpful: 'Was this answer helpful?',
    good: 'Good answer',
    bad: 'Bad answer',
    whatWrong: 'What was the problem?',
    whatGood: 'What was good?',
    comment: 'Add details (optional)',
    commentPlaceholder: 'What should the answer have said?',
    send: 'Send feedback',
    thanks: 'Thanks. Your feedback helps improve the answers.',
    change: 'Change',
    privacy: 'Your rating and this conversation are reviewed by the product team. They are never shared outside it.',
    
    
    suggest: 'Suggest a correction',
    yourVersion: 'Your correction',
    source: 'Source that shows it (optional)',
    sourcePlaceholder: 'https://',
    preview: 'Preview',
    submit: 'Send correction',
    corrected: 'Correction sent. The team checks it before anything changes.',
    cancel: 'Cancel',
    rate: 'Rate this answer',
    overall: 'Overall',
    sendRating: 'Send rating',
    rated: 'Rating saved.',
    of: '{n} of 5',
    report: 'Report a problem',
    reportTitle: 'Report this answer',
    category: 'What kind of problem?',
    details: 'Details',
    detailsPlaceholder: 'Describe what happened',
    include: 'Include this conversation',
    includeNote: 'The team can read the messages to understand the problem.',
    reported: 'Report received. We answer within two working days.',
    close: 'Close',
    required: 'Choose a kind of problem first.',
    
    positive: 'Helpful',
    negative: 'Not helpful',
    ratio: 'Helpful share',
    topReasons: 'Most named problems',
    recent: 'Latest comments',
    all: 'All',
    weeks: 'Per week',
    question: 'Question',
  },
  de: {
    helpful: 'War diese Antwort hilfreich?',
    good: 'Gute Antwort',
    bad: 'Schlechte Antwort',
    whatWrong: 'Was war das Problem?',
    whatGood: 'Was war gut?',
    comment: 'Details ergänzen (optional)',
    commentPlaceholder: 'Was hätte die Antwort sagen sollen?',
    send: 'Feedback senden',
    thanks: 'Danke. Dein Feedback hilft, die Antworten zu verbessern.',
    change: 'Ändern',
    privacy: 'Deine Bewertung und dieses Gespräch sieht nur das Produktteam. Nichts davon geht nach außen.',
    
    
    suggest: 'Korrektur vorschlagen',
    yourVersion: 'Deine Korrektur',
    source: 'Quelle, die es belegt (optional)',
    sourcePlaceholder: 'https://',
    preview: 'Vorschau',
    submit: 'Korrektur senden',
    corrected: 'Korrektur gesendet. Das Team prüft sie, bevor sich etwas ändert.',
    cancel: 'Abbrechen',
    rate: 'Diese Antwort bewerten',
    overall: 'Gesamt',
    sendRating: 'Bewertung senden',
    rated: 'Bewertung gespeichert.',
    of: '{n} von 5',
    report: 'Problem melden',
    reportTitle: 'Diese Antwort melden',
    category: 'Welche Art von Problem?',
    details: 'Details',
    detailsPlaceholder: 'Beschreib, was passiert ist',
    include: 'Dieses Gespräch mitsenden',
    includeNote: 'Das Team kann die Nachrichten lesen, um das Problem zu verstehen.',
    reported: 'Meldung erhalten. Wir antworten innerhalb von zwei Werktagen.',
    close: 'Schließen',
    required: 'Wähl zuerst eine Art von Problem.',
    
    positive: 'Hilfreich',
    negative: 'Nicht hilfreich',
    ratio: 'Anteil hilfreich',
    topReasons: 'Häufigste Probleme',
    recent: 'Neueste Kommentare',
    all: 'Alle',
    weeks: 'Je Woche',
    question: 'Frage',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = FeedbackOnAnswerProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

const CSS =
  '@keyframes fba-up{from{opacity:0;transform:translateY(8px)}}@keyframes fba-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}@keyframes fba-bar{from{transform:scaleX(0)}}@keyframes fba-in{from{opacity:0;transform:translateY(10px) scale(.98)}}@keyframes fba-draw{from{stroke-dashoffset:1}}' +
  '@media (prefers-reduced-motion:no-preference){.fba-up{animation:fba-up .45s var(--ease-kit) var(--fba-d,0ms) both}.fba-pop{animation:fba-pop .5s var(--ease-kit)}.fba-bar{transform-origin:left;animation:fba-bar 1s var(--ease-kit) var(--fba-d,0ms) both}.fba-in{animation:fba-in .35s var(--ease-kit) both}}';

function Style() {
  return (
    <style href="kit-feedback-on-answer" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function Stage({ title, children, className, wide }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[44rem]')}>
          {title && (
            <Reveal className="mb-6 sm:mb-8">
              <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** The answer card the feedback hangs on */
function AnswerCard({ answer, children, highlight }: { answer?: FeedbackOnAnswerProps['answer']; children?: React.ReactNode; highlight?: string }) {
  if (!answer) return <>{children}</>;
  const parts = highlight && answer.text.includes(highlight) ? answer.text.split(highlight) : null;
  return (
    <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
      <div className="space-y-4 p-5 sm:p-6">
        <p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] px-4 py-2.5 text-[14.5px] text-foreground">{answer.question}</p>
        <div className="flex gap-3">
          <span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
            <Icon name="sparkle" size={16} />
          </span>
          <div className="min-w-0 flex-1">
            {answer.assistant && <p className="mb-1 text-[12.5px] font-medium text-muted-foreground">{answer.assistant}</p>}
            <p className="text-pretty text-[15px] leading-relaxed text-foreground">
              {parts ? (
                <>
                  {parts[0]}
                  <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_16%,transparent)] text-foreground underline decoration-[color-mix(in_oklab,var(--destructive)_70%,transparent)] decoration-wavy underline-offset-4 [box-decoration-break:clone]">{highlight}</mark>
                  {parts[1]}
                </>
              ) : (
                answer.text
              )}
            </p>
          </div>
        </div>
      </div>
      {children}
    </div>
  );
}

const chip = 'focus-visible:-outline-offset-2 inline-flex min-h-11 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-[background-color,border-color,color] duration-200 sm:min-h-9';
const chipOn = 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground';
const chipOff = 'border-border bg-background text-muted-foreground hover:text-foreground hover:border-border-strong';
const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9';
const quietBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9';
const field = 'w-full rounded-xl border border-border bg-background px-3.5 py-2.5 text-[14.5px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)] focus-visible:outline-none';

function Done({ children }: { children: React.ReactNode }) {
  return (
    <p role="status" className="fba-up flex items-start gap-2 text-[13.5px] leading-snug text-success-foreground">
      <Icon name="check-circle" size={16} className="mt-px shrink-0" />
      {children}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* thumbs                                                              */
/* ------------------------------------------------------------------ */

function Thumbs({ t, title, answer, reasons = [], praise = [], defaultRating, onSubmit, className }: P) {
  const [rating, setRating] = useState<'up' | 'down' | null>(defaultRating ?? null);
  const [picked, setPicked] = useState<string[]>([]);
  const [note, setNote] = useState('');
  const [sent, setSent] = useState(false);
  const [pop, setPop] = useState(0);
  const list = rating === 'up' ? praise : reasons;
  const rate = (r: 'up' | 'down') => {
    setRating(rating === r ? null : r);
    setPicked([]);
    setSent(false);
    setPop((n) => n + 1);
  };
  return (
    <Stage title={title} className={className}>
      <Reveal effect="scale" delay={60}>
        <AnswerCard answer={answer}>
          <div className="border-t border-border px-5 py-3.5 sm:px-6">
            <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
              <p className="mr-auto text-[13.5px] text-muted-foreground">{t.helpful}</p>
              <div className="flex gap-1.5">
                {(['up', 'down'] as const).map((r) => (
                  <button
                    key={r}
                    type="button"
                    aria-pressed={rating === r}
                    aria-label={r === 'up' ? t.good : t.bad}
                    onClick={() => rate(r)}
                    className={cn('grid size-11 place-items-center rounded-lg border transition-colors duration-200 sm:size-9', rating === r ? (r === 'up' ? 'border-[color-mix(in_oklab,var(--success)_50%,var(--border))] bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground' : 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive') : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}
                  >
                    <Icon key={rating === r ? pop : 0} name={r === 'up' ? 'thumbs-up' : 'thumbs-down'} size={17} className={rating === r ? 'fba-pop' : undefined} />
                  </button>
                ))}
              </div>
            </div>
            <div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', rating ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
              <div className="overflow-hidden" inert={!rating || undefined}>
                <div className="pt-4">
                  {sent ? (
                    <div className="flex items-center justify-between gap-3">
                      <Done>{t.thanks}</Done>
                      <button type="button" onClick={() => setSent(false)} className="inline-flex h-11 shrink-0 items-center rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
                        {t.change}
                      </button>
                    </div>
                  ) : (
                    <form
                      onSubmit={(e) => {
                        e.preventDefault();
                        onSubmit?.({ rating, reasons: picked, note });
                        setSent(true);
                      }}
                    >
                      <fieldset>
                        <legend className="mb-2 text-[13px] font-medium text-foreground">{rating === 'up' ? t.whatGood : t.whatWrong}</legend>
                        <div className="flex flex-wrap gap-2">
                          {list.map((r, i) => {
                            const on = picked.includes(r.id);
                            return (
                              <button key={`${rating}-${r.id}`} type="button" aria-pressed={on} onClick={() => setPicked((p) => (on ? p.filter((x) => x !== r.id) : [...p, r.id]))} className={cn(chip, on ? chipOn : chipOff, 'fba-up')} style={{ '--fba-d': `${i * 40}ms` } as React.CSSProperties}>
                                {on && <Icon name="check" size={14} />}
                                {r.label}
                              </button>
                            );
                          })}
                        </div>
                      </fieldset>
                      <label className="mt-3 block">
                        <span className="mb-1.5 block text-[12.5px] text-muted-foreground">{t.comment}</span>
                        <textarea rows={2} value={note} onChange={(e) => setNote(e.target.value)} placeholder={t.commentPlaceholder} className={cn(field, 'resize-none [field-sizing:content] min-h-[4.5rem]')} />
                      </label>
                      <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                        <p className="flex items-start gap-1.5 text-[12px] leading-snug text-muted-foreground">
                          <Icon name="lock" size={13} className="mt-px shrink-0" />
                          {t.privacy}
                        </p>
                        <button type="submit" disabled={!picked.length && !note.trim()} className={cn(primaryBtn, 'shrink-0')}>
                          {t.send}
                        </button>
                      </div>
                    </form>
                  )}
                </div>
              </div>
            </div>
          </div>
        </AnswerCard>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* correction                                                          */
/* ------------------------------------------------------------------ */

function Correction({ t, title, answer, wrong = '', correction, onSubmit, className }: P) {
  const [open, setOpen] = useState(true);
  const [text, setText] = useState(correction ?? wrong);
  const [src, setSrc] = useState('');
  const [sent, setSent] = useState(false);
  const area = useRef<HTMLTextAreaElement>(null);
  const changed = text.trim() !== wrong.trim() && text.trim().length > 0;
  return (
    <Stage title={title} className={className}>
      <Reveal effect="scale" delay={60}>
        <AnswerCard answer={answer} highlight={sent ? undefined : wrong}>
          <div className="border-t border-border px-5 py-4 sm:px-6">
            {!open && !sent && (
              <button type="button" onClick={() => (setOpen(true), requestAnimationFrame(() => area.current?.focus()))} className={quietBtn}>
                <Icon name="edit" size={15} />
                {t.suggest}
              </button>
            )}
            {sent && <Done>{t.corrected}</Done>}
            {open && !sent && (
              <form
                className="fba-up"
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!changed) return;
                  onSubmit?.({ before: wrong, after: text, source: src });
                  setSent(true);
                }}
              >
                <label className="block">
                  <span className="mb-1.5 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
                    <Icon name="edit" size={14} className="text-primary" />
                    {t.yourVersion}
                  </span>
                  <textarea ref={area} rows={3} value={text} onChange={(e) => setText(e.target.value)} className={cn(field, 'resize-none [field-sizing:content] min-h-[5.5rem]')} />
                </label>
                {changed && (
                  <div className="fba-up mt-3 rounded-xl border border-border bg-background/60 p-3.5">
                    <p className="mb-1.5 text-[12px] font-medium text-subtle-foreground">{t.preview}</p>
                    <p className="text-[13.5px] leading-relaxed">
                      <del className="text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{wrong}</del>
                    </p>
                    <p className="mt-1 text-[13.5px] leading-relaxed">
                      <ins className="rounded-[3px] bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-foreground no-underline [box-decoration-break:clone]">{text}</ins>
                    </p>
                  </div>
                )}
                <label className="mt-3 block">
                  <span className="mb-1.5 block text-[12.5px] text-muted-foreground">{t.source}</span>
                  <input type="url" inputMode="url" value={src} onChange={(e) => setSrc(e.target.value)} placeholder={t.sourcePlaceholder} className={cn(field, 'h-11 py-0')} />
                </label>
                <div className="mt-4 flex flex-wrap justify-end gap-2">
                  <button type="button" onClick={() => (setOpen(false), setText(wrong))} className={quietBtn}>
                    {t.cancel}
                  </button>
                  <button type="submit" disabled={!changed} className={primaryBtn}>
                    {t.submit}
                  </button>
                </div>
              </form>
            )}
          </div>
        </AnswerCard>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* rating-scale                                                        */
/* ------------------------------------------------------------------ */

function RatingScale({ t, title, answer, dimensions = [], defaultValues, onSubmit, className }: P) {
  const locale = useLocale();
  const [values, setValues] = useState<Record<string, number>>(defaultValues ?? {});
  const [sent, setSent] = useState(false);
  const rated = dimensions.filter((d) => values[d.id]);
  const avg = rated.length ? rated.reduce((s, d) => s + values[d.id], 0) / rated.length : 0;
  const refs = useRef<Record<string, (HTMLButtonElement | null)[]>>({});
  const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  return (
    <Stage title={title} className={className}>
      <Reveal effect="scale" delay={60}>
        <AnswerCard answer={answer}>
          <form
            className="border-t border-border px-5 py-5 sm:px-6"
            onSubmit={(e) => {
              e.preventDefault();
              onSubmit?.(values);
              setSent(true);
            }}
          >
            <div className="mb-4 flex items-baseline justify-between gap-3">
              <p className="text-[14px] font-medium text-foreground">{t.rate}</p>
              <p className="min-h-6 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
                {avg > 0 && (
                  <span className="fba-up">
                    {t.overall} <span className="text-[17px] font-semibold text-foreground">{nf(avg)}</span> / 5
                  </span>
                )}
              </p>
            </div>
            <Stagger step={70} className="space-y-4">
              {dimensions.map((d) => {
                const v = values[d.id] ?? 0;
                return (
                  <div key={d.id} role="radiogroup" aria-label={d.label}>
                    <div className="mb-2 flex items-baseline justify-between gap-3">
                      <span className="text-[13.5px] font-medium text-foreground">{d.label}</span>
                      <span className="text-[12px] tabular-nums text-subtle-foreground">{v ? fill(t.of, { n: v }) : ''}</span>
                    </div>
                    <div className="grid grid-cols-5 gap-1.5">
                      {[1, 2, 3, 4, 5].map((n) => {
                        const on = n <= v;
                        return (
                          <button
                            key={n}
                            ref={(el) => {
                              (refs.current[d.id] ??= [])[n - 1] = el;
                            }}
                            type="button"
                            role="radio"
                            aria-checked={v === n}
                            aria-label={`${d.label}, ${fill(t.of, { n })}`}
                            tabIndex={v === n || (!v && n === 1) ? 0 : -1}
                            disabled={sent}
                            onClick={() => setValues((s) => ({ ...s, [d.id]: n }))}
                            onKeyDown={(e) => {
                              const dir = ['ArrowRight', 'ArrowUp'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowDown'].includes(e.key) ? -1 : 0;
                              if (!dir) return;
                              e.preventDefault();
                              const next = Math.min(5, Math.max(1, (v || n) + dir));
                              setValues((s) => ({ ...s, [d.id]: next }));
                              refs.current[d.id]?.[next - 1]?.focus();
                            }}
                            className={cn('h-11 rounded-lg border transition-[background-color,border-color,transform] duration-300 ease-[var(--ease-kit)] active:scale-95 sm:h-9', on ? 'border-transparent bg-primary' : 'border-border bg-background hover:border-border-strong')}
                            style={{ transitionDelay: on ? `${(n - 1) * 35}ms` : '0ms' }}
                          >
                            <span className={cn('text-[12.5px] font-semibold tabular-nums', on ? 'text-primary-foreground' : 'text-muted-foreground')}>{n}</span>
                          </button>
                        );
                      })}
                    </div>
                    <div className="mt-1.5 flex justify-between text-[11.5px] text-subtle-foreground">
                      <span>{d.low}</span>
                      <span>{d.high}</span>
                    </div>
                  </div>
                );
              })}
            </Stagger>
            <div className="mt-5 flex items-center justify-between gap-3">
              {sent ? <Done>{t.rated}</Done> : <span />}
              {!sent && (
                <button type="submit" disabled={rated.length < dimensions.length} className={primaryBtn}>
                  {t.sendRating}
                </button>
              )}
            </div>
          </form>
        </AnswerCard>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* report-dialog                                                       */
/* ------------------------------------------------------------------ */

function ReportDialog({ t, title, answer, reasons = [], defaultOpen = true, onSubmit, className }: P) {
  const [open, setOpen] = useState(defaultOpen);
  const [kind, setKind] = useState('');
  const [details, setDetails] = useState('');
  const [include, setInclude] = useState(true);
  const [sent, setSent] = useState(false);
  const [error, setError] = useState(false);
  const id = useId();
  const dialog = useRef<HTMLDivElement>(null);
  const opener = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!open) return;
    const el = dialog.current;
    const keys = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        setOpen(false);
        opener.current?.focus();
      }
      if (e.key !== 'Tab' || !el) return;
      const f = Array.from(el.querySelectorAll<HTMLElement>('button, input, textarea, [tabindex="0"]')).filter((x) => !x.hasAttribute('disabled'));
      if (!f.length) return;
      if (e.shiftKey && document.activeElement === f[0]) (e.preventDefault(), f[f.length - 1].focus());
      else if (!e.shiftKey && document.activeElement === f[f.length - 1]) (e.preventDefault(), f[0].focus());
    };
    document.addEventListener('keydown', keys);
    return () => document.removeEventListener('keydown', keys);
  }, [open]);

  return (
    <Stage title={title} className={className}>
      {/* Card and dialog share one grid cell, so the cell grows with the taller one and nothing is cut */}
      <div className="relative grid">
        <Reveal effect="scale" delay={60} className="[grid-area:1/1]">
          <AnswerCard answer={answer}>
            <div className="flex items-center justify-between gap-3 border-t border-border px-5 py-3 sm:px-6">
              <span className="text-[12.5px] text-muted-foreground">{answer?.assistant}</span>
              <button ref={opener} type="button" aria-haspopup="dialog" aria-expanded={open} onClick={() => (setOpen(true), setSent(false), requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>('input, button:not([aria-label])')?.focus()))} className="inline-flex h-11 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8">
                <Icon name="flag" size={15} />
                {t.report}
              </button>
            </div>
          </AnswerCard>
        </Reveal>
        {open && (
          <>
            <div aria-hidden className="fba-up -m-3 rounded-[1.5rem] bg-scrim backdrop-blur-[2px] [grid-area:1/1] sm:-m-4" onClick={() => setOpen(false)} />
            <div
              ref={dialog}
              role="dialog"
              aria-modal="true"
              aria-labelledby={`${id}-t`}
              className="fba-in relative z-10 mx-auto mb-2 mt-6 h-fit w-full max-w-[30rem] self-start rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)] [grid-area:1/1] sm:mt-10"
            >
              <div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5">
                <h3 id={`${id}-t`} className="text-[15.5px] font-semibold text-foreground">
                  {t.reportTitle}
                </h3>
                <button type="button" aria-label={t.close} onClick={() => (setOpen(false), opener.current?.focus())} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
                  <Icon name="x" size={17} />
                </button>
              </div>
              {sent ? (
                <div className="px-5 py-8 text-center">
                  <span className="mx-auto grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground">
                    <Icon name="check" size={22} />
                  </span>
                  <p className="mx-auto mt-4 max-w-[20rem] text-pretty text-[14px] leading-relaxed text-foreground" role="status">
                    {t.reported}
                  </p>
                  <button type="button" onClick={() => (setOpen(false), opener.current?.focus())} className={cn(quietBtn, 'mt-5')}>
                    {t.close}
                  </button>
                </div>
              ) : (
                <form
                  noValidate
                  className="px-5 py-4"
                  onSubmit={(e) => {
                    e.preventDefault();
                    if (!kind) return setError(true);
                    onSubmit?.({ kind, details, include });
                    setSent(true);
                  }}
                >
                  <fieldset>
                    <legend className="mb-2 text-[13px] font-medium text-foreground">{t.category}</legend>
                    <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
                      {reasons.map((r) => (
                        <label key={r.id} className={cn('flex min-h-11 cursor-pointer items-center gap-2.5 rounded-lg border px-3 text-[13.5px] transition-colors', kind === r.id ? chipOn : 'border-border text-foreground hover:bg-accent/60')}>
                          <input type="radio" name={`${id}-kind`} value={r.id} checked={kind === r.id} onChange={() => (setKind(r.id), setError(false))} className="size-4 accent-[var(--primary)]" />
                          {r.label}
                        </label>
                      ))}
                    </div>
                    {error && (
                      <p role="alert" className="fba-up mt-2 flex items-center gap-1.5 text-[12.5px] text-destructive">
                        <Icon name="alert-circle" size={14} />
                        {t.required}
                      </p>
                    )}
                  </fieldset>
                  <label className="mt-3 block">
                    <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.details}</span>
                    <textarea rows={3} value={details} onChange={(e) => setDetails(e.target.value)} placeholder={t.detailsPlaceholder} className={cn(field, 'resize-none')} />
                  </label>
                  <label className="mt-3 flex cursor-pointer items-start gap-2.5 rounded-xl bg-background/60 p-3">
                    <input type="checkbox" checked={include} onChange={(e) => setInclude(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
                    <span>
                      <span className="block text-[13.5px] font-medium text-foreground">{t.include}</span>
                      <span className="block text-[12.5px] leading-snug text-muted-foreground">{t.includeNote}</span>
                    </span>
                  </label>
                  <div className="mt-4 flex justify-end gap-2">
                    <button type="button" onClick={() => (setOpen(false), opener.current?.focus())} className={quietBtn}>
                      {t.cancel}
                    </button>
                    <button type="submit" className={primaryBtn}>
                      {t.report}
                    </button>
                  </div>
                </form>
              )}
            </div>
          </>
        )}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* insights                                                            */
/* ------------------------------------------------------------------ */

function Insights({ t, title, period, positive = 0, negative = 0, trend = [], topReasons = [], comments = [], className }: P) {
  const locale = useLocale();
  const [filter, setFilter] = useState<'all' | 'up' | 'down'>('all');
  const total = positive + negative;
  const share = total ? positive / total : 0;
  const pct = (n: number) => (locale === 'de' ? `${Math.round(n * 100)} %` : `${Math.round(n * 100)}%`);
  const maxReason = Math.max(1, ...topReasons.map((r) => r.count));
  const shown = comments.filter((c) => filter === 'all' || c.rating === filter);
  const w = 160;
  const h = 44;
  const pts = trend.map((v, i) => [round((i / Math.max(1, trend.length - 1)) * w), round(h - 4 - v * (h - 8))]);
  const path = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');

  return (
    <Stage title={title} className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)] lg:gap-6">
        <div className="space-y-4">
          <Reveal delay={60}>
            <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
              <div className="flex items-start justify-between gap-4">
                <div>
                  <p className="text-[12.5px] text-muted-foreground">{t.ratio}</p>
                  <p className="mt-1 text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{pct(share)}</p>
                  <p className="mt-1.5 text-[12px] text-subtle-foreground">{period}</p>
                </div>
                {pts.length > 1 && (
                  <svg aria-hidden viewBox={`0 0 ${w} ${h}`} className="h-11 w-40 overflow-visible">
                    <path d={`${path} L${w} ${h} L0 ${h} Z`} style={{ fill: 'color-mix(in oklab, var(--success) 14%, transparent)' }} />
                    <path d={path} fill="none" stroke="var(--success)" strokeWidth="1.6" strokeLinejoin="round" pathLength={1} className="[stroke-dasharray:1] motion-safe:animate-[fba-draw_1.2s_var(--ease-kit)_both]" />
                    <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="2.6" fill="var(--success)" />
                    <title>{t.weeks}</title>
                  </svg>
                )}
              </div>
              <div aria-hidden className="mt-4 flex h-2.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--destructive)_55%,var(--card))]">
                <span className="fba-bar h-full rounded-r-full bg-success" style={{ width: `${round(share * 100, 1)}%` }} />
              </div>
              <div className="mt-2.5 flex justify-between text-[12.5px] tabular-nums">
                <span className="flex items-center gap-1.5 text-success-foreground">
                  <Icon name="thumbs-up" size={14} />
                  {t.positive} {positive.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
                </span>
                <span className="flex items-center gap-1.5 text-destructive">
                  {t.negative} {negative.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
                  <Icon name="thumbs-down" size={14} />
                </span>
              </div>
            </div>
          </Reveal>
          <Reveal delay={140}>
            <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
              <p className="mb-3 text-[13px] font-medium text-foreground">{t.topReasons}</p>
              <ul className="space-y-3">
                {topReasons.map((r, i) => (
                  <li key={r.label}>
                    <div className="mb-1 flex justify-between gap-3 text-[13px]">
                      <span className="text-foreground">{r.label}</span>
                      <span className="tabular-nums text-muted-foreground">{r.count}</span>
                    </div>
                    <div className="h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
                      <span className="fba-bar block h-full rounded-full bg-[color-mix(in_oklab,var(--destructive)_70%,var(--card))]" style={{ width: `${round((r.count / maxReason) * 100, 1)}%`, '--fba-d': `${200 + i * 90}ms` } as React.CSSProperties} />
                    </div>
                  </li>
                ))}
              </ul>
            </div>
          </Reveal>
        </div>
        <Reveal delay={200}>
          <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
              <p className="text-[13px] font-medium text-foreground">{t.recent}</p>
              <div role="group" aria-label={t.recent} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
                {(
                  [
                    ['all', t.all, null],
                    ['up', t.positive, 'thumbs-up'],
                    ['down', t.negative, 'thumbs-down'],
                  ] as [typeof filter, string, IconName | null][]
                ).map(([k, label, icon]) => (
                  <button key={k} type="button" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn('inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-colors sm:h-8 sm:min-w-0', filter === k ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                    {icon && <Icon name={icon} size={13} />}
                    <span className={icon ? 'max-sm:sr-only' : undefined}>{label}</span>
                  </button>
                ))}
              </div>
            </div>
            <ul className="divide-y divide-border">
              {shown.map((c, i) => (
                <li key={`${filter}-${c.id}`} className="fba-up flex gap-3 px-5 py-4" style={{ '--fba-d': `${i * 50}ms` } as React.CSSProperties}>
                  <span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-full', c.rating === 'up' ? 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_13%,var(--card))] text-destructive')}>
                    <Icon name={c.rating === 'up' ? 'thumbs-up' : 'thumbs-down'} size={13} />
                  </span>
                  <div className="min-w-0 flex-1">
                    <p className="text-pretty text-[14px] leading-relaxed text-foreground">{c.text}</p>
                    <p className="mt-1 truncate text-[12.5px] text-muted-foreground">
                      {t.question} · {c.question}
                    </p>
                    <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-subtle-foreground">
                      {c.reason && <span className="rounded-md bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 py-0.5 text-muted-foreground">{c.reason}</span>}
                      {c.time}
                    </p>
                  </div>
                </li>
              ))}
            </ul>
          </div>
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Feedback on AI answers: thumbs with reasons and a comment, a correction
 * with preview and source, a rating on several scales, a report dialog with
 * privacy choice and an insights view for the team.
 */
export function FeedbackOnAnswer({ variant = 'thumbs', labels, ...props }: FeedbackOnAnswerProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'correction':
        return <Correction {...p} />;
      case 'rating-scale':
        return <RatingScale {...p} />;
      case 'report-dialog':
        return <ReportDialog {...p} />;
      case 'insights':
        return <Insights {...p} />;
      default:
        return <Thumbs {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install Answer feedback?

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

Which variants does Answer feedback have?

5 variants, Thumbs with reasons, Suggest a correction, Rating scales, Report dialog and Feedback insights. You pick one with the variant prop, for example variant="correction".

Can I translate the texts of Answer feedback?

Yes. Answer feedback carries 42 texts of its own in English and German, such as “Was this answer helpful?”, “Good answer” and “Bad answer”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Answer feedback?

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

Can I adjust Answer feedback without code?

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

Often searched

Jump to

Copied to the clipboard