Zum Inhalt springen
UI Kit
Accent

NPS und Bewertungen

Eine schnelle Bewertung und eine Folgefrage, die zur Antwort passt. Ein niedriger Wert fragt, was gefehlt hat, ein hoher, was begeistert, beide mit Stichworten zum Antippen und Platz für einen Kommentar.

Eine von 3 Components in Umfragen und Feedback

5 Variants

Variants

Weiterempfehlung

Die klassische Frage von 0 bis 10 als Kacheln, die nacheinander aufsteigen. Auf dem Telefon bricht die Skala in zwei Reihen, so bleibt jede Kachel ein volles Tippziel. Die Folgefrage öffnet sich nach der Wahl.

Im Studio öffnen

Zufriedenheit mit Gesichtern

Fünf gezeichnete Gesichter von sehr unzufrieden bis begeistert, der Mund biegt sich mit der Stimmung. Das gewählte Gesicht wächst mit Federung und trägt den Akzent, die Folgefrage passt zur Stimmung.

Im Studio öffnen

War das hilfreich

Zwei Knöpfe am Ende eines Artikels. Ja bedankt sich sofort, Nein öffnet Gründe und Kommentar mit weicher Höhe. Der Daumen nickt kurz beim Drücken.

Im Studio öffnen

Aufwand

Wie leicht etwas war, auf sieben Stufen. Ein Messbogen neben der Karte neigt sich mit steigendem Wert zu leicht, die gewählte Kachel gleitet über die Skala und Pfeiltasten bewegen sie.

Im Studio öffnen

Support-Bewertung

Die Abschlusskarte eines Support-Chats mit der Person, die geholfen hat, und den letzten Nachrichten. Sterne leuchten unter dem Zeiger auf und setzen sich nach der Wahl nacheinander.

Im Studio öffnen

Details

Was NPS und Bewertungen mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/nps-feedback.tsx
Bringt mit
utilskitbuttonicontextarearevealsectionform-flow
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 21 eigene Texte

Props

Die Schnittstelle NpsFeedbackProps aus components/ui/nps-feedback.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    NpsFeedbackVariant

    npscsatthumbseffortsupport-rating

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • questionPflicht
    string

    The rating question

  • lowFollowUpoptional
    string

    Follow-up for a low score (detractors, unhappy, no)

  • highFollowUpoptional
    string

    Follow-up for a high score

  • lowReasonsoptional
    string[]

    Tags to pick for a low and a high score

  • highReasonsoptional
    string[]
  • lowoptional
    string

    Words under both ends of the scale

  • highoptional
    string
  • articleoptional
    { title: string; text: string; updated?: string }

    thumbs: the article above the question

  • agentoptional
    { name: string; role?: string; initials?: string }

    support-rating: who helped

  • messagesoptional
    { from: 'agent' | 'visitor'; text: string }[]

    support-rating: the last lines of the conversation

  • thanksoptional
    { title: string; text?: string }

    Thanks after sending

  • onSubmitoptional
    (data: { score: number | null; reasons: string[]; comment: string }) => void

Eingebaute Texte

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

Alle 21 Texte anzeigen
sendEnglisch Send feedbackDeutsch Feedback senden
sendShortEnglisch SendDeutsch Senden
commentEnglisch Anything else you want to tell us?Deutsch Möchtest du uns noch etwas sagen?
reasonsEnglisch What was it mostly about?Deutsch Worum ging es vor allem?
yesEnglisch YesDeutsch Ja
noEnglisch NoDeutsch Nein
helpfulEnglisch Was this page helpful?Deutsch War diese Seite hilfreich?
gladTitleEnglisch Glad it helpedDeutsch Schön, dass es geholfen hat
gladTextEnglisch Thank you for letting us know.Deutsch Danke für deine Rückmeldung.
thanksTitleEnglisch Thank you, that helps usDeutsch Danke, das hilft uns
thanksTextEnglisch We read every answer and follow up when we can do better.Deutsch Wir lesen jede Antwort und melden uns, wenn wir etwas besser machen können.
changeEnglisch Change ratingDeutsch Bewertung ändern
scoreEnglisch Score {n}Deutsch Wert {n}
pickScoreEnglisch Please choose a score first, then we can take your comment.Deutsch Bitte wähle zuerst einen Wert, dann nehmen wir deinen Kommentar gern auf.
ratedEnglisch You rated {n}Deutsch Du hast {n} gewählt
notRatedEnglisch Not rated yetDeutsch Noch nicht bewertet
inBetweenEnglisch In betweenDeutsch Dazwischen
starsEnglisch {n} of 5 starsDeutsch {n} von 5 Sternen
conversationEnglisch ConversationDeutsch Unterhaltung
closedEnglisch Chat closedDeutsch Chat beendet
youEnglisch YouDeutsch Du

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, group und status.
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 583 Zeilen
components/ui/nps-feedback.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { ChipGroup, DoneMark, FlowHead, ScalePicker, Unfold } from '@/components/ui/form-flow';

/*
 * One quick rating and a follow-up question that fits the answer. The
 * follow-up opens with a soft height once a score is chosen and asks
 * something different for a low and a high score. Sends
 * { score, reasons, comment } to `onSubmit`.
 *
 *   nps             0 to 10, two rows of tiles on phones
 *   csat            five drawn faces from unhappy to delighted
 *   thumbs          "Was this helpful?" under an article, reasons on a no
 *   effort          how easy something was, on seven points
 *   support-rating  the closing card of a support chat with the person who helped
 */

export type NpsFeedbackVariant = 'nps' | 'csat' | 'thumbs' | 'effort' | 'support-rating';

export interface NpsFeedbackProps {
  variant?: NpsFeedbackVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** The rating question */
  question: string;
  /** Follow-up for a low score (detractors, unhappy, no) */
  lowFollowUp?: string;
  /** Follow-up for a high score */
  highFollowUp?: string;
  /** Tags to pick for a low and a high score */
  lowReasons?: string[];
  highReasons?: string[];
  /** Words under both ends of the scale */
  low?: string;
  high?: string;
  /** thumbs: the article above the question */
  article?: { title: string; text: string; updated?: string };
  /** support-rating: who helped */
  agent?: { name: string; role?: string; initials?: string };
  /** support-rating: the last lines of the conversation */
  messages?: { from: 'agent' | 'visitor'; text: string }[];
  /** Thanks after sending */
  thanks?: { title: string; text?: string };
  onSubmit?: (data: { score: number | null; reasons: string[]; comment: string }) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    send: 'Send feedback',
    sendShort: 'Send',
    comment: 'Anything else you want to tell us?',
    reasons: 'What was it mostly about?',
    yes: 'Yes',
    no: 'No',
    helpful: 'Was this page helpful?',
    gladTitle: 'Glad it helped',
    gladText: 'Thank you for letting us know.',
    thanksTitle: 'Thank you, that helps us',
    thanksText: 'We read every answer and follow up when we can do better.',
    change: 'Change rating',
    score: 'Score {n}',
    pickScore: 'Please choose a score first, then we can take your comment.',
    rated: 'You rated {n}',
    notRated: 'Not rated yet',
    inBetween: 'In between',
    stars: '{n} of 5 stars',
    conversation: 'Conversation',
    closed: 'Chat closed',
    you: 'You',
  },
  de: {
    send: 'Feedback senden',
    sendShort: 'Senden',
    comment: 'Möchtest du uns noch etwas sagen?',
    reasons: 'Worum ging es vor allem?',
    yes: 'Ja',
    no: 'Nein',
    helpful: 'War diese Seite hilfreich?',
    gladTitle: 'Schön, dass es geholfen hat',
    gladText: 'Danke für deine Rückmeldung.',
    thanksTitle: 'Danke, das hilft uns',
    thanksText: 'Wir lesen jede Antwort und melden uns, wenn wir etwas besser machen können.',
    change: 'Bewertung ändern',
    score: 'Wert {n}',
    pickScore: 'Bitte wähle zuerst einen Wert, dann nehmen wir deinen Kommentar gern auf.',
    rated: 'Du hast {n} gewählt',
    notRated: 'Noch nicht bewertet',
    inBetween: 'Dazwischen',
    stars: '{n} von 5 Sternen',
    conversation: 'Unterhaltung',
    closed: 'Chat beendet',
    you: 'Du',
  },
};

type Texts = (typeof TEXTS)['en'];

const FACES = { en: ['Very unhappy', 'Unhappy', 'Okay', 'Happy', 'Delighted'], de: ['Sehr unzufrieden', 'Unzufrieden', 'Okay', 'Zufrieden', 'Begeistert'] };

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

function FollowUp({ p, t, isLow, reasons, setReasons, comment, setComment, open, compact }: { p: NpsFeedbackProps; t: Texts; isLow: boolean; reasons: string[]; setReasons: (r: string[]) => void; comment: string; setComment: (c: string) => void; open: boolean; compact?: boolean }) {
  const list = (isLow ? p.lowReasons : p.highReasons) ?? [];
  const q = (isLow ? p.lowFollowUp : p.highFollowUp) ?? t.comment;
  return (
    <Unfold open={open}>
      <div className={cn('grid gap-5', compact ? 'pt-5' : 'pt-7')}>
        {list.length > 0 && <ChipGroup key={isLow ? 'low' : 'high'} legend={q} options={list.map((r) => ({ value: r, label: r }))} multiple value={reasons} onChange={setReasons} />}
        <Textarea label={list.length ? t.comment : q} optional value={comment} onChange={(e) => setComment(e.target.value)} minRows={compact ? 2 : 3} maxLength={500} />
      </div>
    </Unfold>
  );
}

function Thanks({ p, t, title, text, onChange }: { p: NpsFeedbackProps; t: Texts; title?: string; text?: string; onChange?: () => void }) {
  const ref = useRef<HTMLParagraphElement>(null);
  useEffect(() => ref.current?.focus({ preventScroll: true }), []);
  return (
    <div role="status" className="kit-rise flex flex-col items-center py-4 text-center">
      <DoneMark size={52} />
      <p ref={ref} tabIndex={-1} className="mt-4 text-[19px] font-semibold tracking-[-0.02em] text-foreground outline-none">
        {title ?? p.thanks?.title ?? t.thanksTitle}
      </p>
      <p className="mt-1.5 max-w-[26rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{text ?? p.thanks?.text ?? t.thanksText}</p>
      {onChange && (
        <Button variant="ghost" size="sm" iconLeft="rotate-ccw" onClick={onChange} className="mt-3">
          {t.change}
        </Button>
      )}
    </div>
  );
}

function useFeedback(p: NpsFeedbackProps) {
  const [score, setScore] = useState<number | null>(null);
  const [reasons, setReasons] = useState<string[]>([]);
  const [comment, setComment] = useState('');
  const [sent, setSent] = useState(false);
  const [error, setError] = useState(false);
  const send = () => {
    if (score === null) {
      setError(true);
      return false;
    }
    p.onSubmit?.({ score, reasons, comment });
    setSent(true);
    return true;
  };
  const pick = (n: number) => {
    setScore(n);
    setError(false);
  };
  return { score, pick, reasons, setReasons, comment, setComment, sent, setSent, send, error };
}

/* ------------------------------------------------------------------ */
/* nps                                                                 */
/* ------------------------------------------------------------------ */

function Nps({ p, t }: { p: NpsFeedbackProps; t: Texts }) {
  const id = useId();
  const f = useFeedback(p);
  const [viewRef, inView] = useInView<HTMLDivElement>();
  const isLow = f.score !== null && f.score <= 6;
  return (
    <Reveal effect="scale" className="kit-card mx-auto max-w-[46rem] p-6 sm:p-10">
      {f.sent ? (
        <Thanks p={p} t={t} onChange={() => f.setSent(false)} />
      ) : (
        <form
          noValidate
          onSubmit={(e) => {
            e.preventDefault();
            if (!f.send()) document.getElementById(`${id}-0`)?.focus();
          }}
        >
          <fieldset aria-describedby={`${id}-ends${f.error ? ` ${id}-err` : ''}`} className="min-w-0">
            <legend className="text-balance text-[19px] font-semibold leading-snug tracking-[-0.02em] text-foreground sm:text-[21px]">{p.question}</legend>
            {/* Eleven tiles in a row from tablet up, two centred rows on phones (0 to 5, 6 to 10) */}
            <div ref={viewRef} className={cn('kit-stagger mt-6 flex flex-wrap justify-center gap-1.5 sm:grid sm:grid-cols-11 sm:gap-2', !inView && 'kit-stagger-wait')} style={{ '--kit-step': '40ms' } as React.CSSProperties}>
              {Array.from({ length: 11 }, (_, n) => {
                const on = f.score === n;
                return (
                  <label
                    key={n}
                    htmlFor={`${id}-${n}`}
                    className={cn(
                      'group/n relative grid h-12 w-[calc((100%-1.875rem)/6)] cursor-pointer place-items-center rounded-lg border text-[16px] sm:w-auto font-semibold tabular-nums transition-[background-color,border-color,color,scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.5,0.64,1)] sm:h-14',
                      on ? 'scale-[1.06] border-primary bg-primary text-primary-foreground shadow-[var(--shadow-md)]' : 'border-border bg-card text-foreground hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0',
                      f.error && !on && 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]',
                      'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]',
                    )}
                  >
                    <input id={`${id}-${n}`} type="radio" name={`${id}-score`} value={n} checked={on} onChange={() => f.pick(n)} aria-invalid={f.error || undefined} className="sr-only" />
                    {n}
                  </label>
                );
              })}
            </div>
            <p id={`${id}-ends`} className="mt-2.5 flex justify-between gap-4 text-[12.5px] text-muted-foreground">
              <span>{p.low}</span>
              <span className="text-end">{p.high}</span>
            </p>
            {f.error && (
              <p id={`${id}-err`} role="alert" className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {t.pickScore}
              </p>
            )}
          </fieldset>
          <FollowUp p={p} t={t} isLow={isLow} reasons={f.reasons} setReasons={f.setReasons} comment={f.comment} setComment={f.setComment} open={f.score !== null} />
          <div className="mt-7 flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
            <p aria-live="polite" className="text-[13px] text-muted-foreground max-sm:text-center">
              {f.score !== null ? t.rated.replace('{n}', String(f.score)) : ''}
            </p>
            <Button type="submit" icon="send" className="max-sm:w-full">
              {t.send}
            </Button>
          </div>
        </form>
      )}
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* csat                                                                */
/* ------------------------------------------------------------------ */

/** A face drawn on a 40 grid: the mouth bends from a frown (0) to a wide smile (4) */
function Face({ level, on }: { level: number; on: boolean }) {
  const bend = [-5, -2.5, 0, 3, 6][level] ?? 0;
  const mouth = `M13 ${round1(27 - bend / 3)} Q20 ${round1(27 + bend)} 27 ${round1(27 - bend / 3)}`;
  return (
    <svg viewBox="0 0 40 40" aria-hidden className="size-11 shrink-0 overflow-visible sm:size-14">
      <circle cx="20" cy="20" r="18" className={cn('transition-[fill,stroke] duration-300', on ? 'fill-[color-mix(in_oklab,var(--primary)_18%,var(--card))] stroke-primary' : 'fill-transparent stroke-border-strong group-hover/face:stroke-[color-mix(in_oklab,var(--foreground)_45%,var(--border-strong))]')} strokeWidth="1.6" />
      <circle cx="14.5" cy="16.5" r="1.7" className={on ? 'fill-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'fill-muted-foreground'} />
      <circle cx="25.5" cy="16.5" r="1.7" className={on ? 'fill-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'fill-muted-foreground'} />
      <path d={mouth} fill="none" strokeWidth="1.9" strokeLinecap="round" className={on ? 'stroke-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'stroke-muted-foreground'} />
    </svg>
  );
}
const round1 = (n: number) => Math.round(n * 10) / 10;

function Csat({ p, t }: { p: NpsFeedbackProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const f = useFeedback(p);
  const isLow = f.score !== null && f.score <= 2;
  return (
    <Reveal effect="scale" className="kit-card mx-auto max-w-[44rem] p-6 sm:p-10">
      {f.sent ? (
        <Thanks p={p} t={t} onChange={() => f.setSent(false)} />
      ) : (
        <form
          noValidate
          onSubmit={(e) => {
            e.preventDefault();
            if (!f.send()) document.getElementById(`${id}-0`)?.focus();
          }}
        >
          <fieldset className="min-w-0" aria-describedby={f.error ? `${id}-err` : undefined}>
            <legend className="mx-auto max-w-[32rem] text-balance text-center text-[19px] font-semibold leading-snug tracking-[-0.02em] text-foreground sm:text-[21px]">{p.question}</legend>
            <div className="kit-stagger mx-auto mt-7 grid max-w-[34rem] grid-cols-5 gap-1 sm:gap-3" style={{ '--kit-step': '60ms' } as React.CSSProperties}>
              {FACES[locale].map((label, i) => {
                const on = f.score === i;
                return (
                  <label
                    key={label}
                    htmlFor={`${id}-${i}`}
                    className={cn(
                      'group/face flex cursor-pointer flex-col items-center gap-2 rounded-xl px-1 py-3 transition-[background-color,box-shadow] duration-300 hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]',
                      'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]',
                    )}
                  >
                    <input id={`${id}-${i}`} type="radio" name={`${id}-face`} value={i + 1} checked={on} onChange={() => f.pick(i)} aria-invalid={f.error || undefined} className="sr-only" />
                    <span className={cn('transition-transform duration-500 ease-[cubic-bezier(0.34,1.7,0.64,1)] group-hover/face:-translate-y-0.5 motion-reduce:transition-none', on && 'scale-[1.18]')}>
                      <Face level={i} on={on} />
                    </span>
                    <span className={cn('text-center text-[11.5px] leading-tight transition-colors sm:text-[12.5px]', on ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{label}</span>
                  </label>
                );
              })}
            </div>
            {f.error && (
              <p id={`${id}-err`} role="alert" className="kit-rise mt-3 flex items-start justify-center gap-1.5 text-center text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {t.pickScore}
              </p>
            )}
          </fieldset>
          <FollowUp p={p} t={t} isLow={isLow} reasons={f.reasons} setReasons={f.setReasons} comment={f.comment} setComment={f.setComment} open={f.score !== null} />
          <div className="mt-7 flex justify-center">
            <Button type="submit" icon="send" className="max-sm:w-full">
              {t.send}
            </Button>
          </div>
        </form>
      )}
    </Reveal>
  );
}

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

function Thumbs({ p, t }: { p: NpsFeedbackProps; t: Texts }) {
  const qid = useId();
  const f = useFeedback(p);
  const [answer, setAnswer] = useState<'yes' | 'no' | null>(null);
  const bounce = (el: Element) => {
    if (prefersReducedMotion() || typeof el.animate !== 'function') return;
    el.animate([{ transform: 'scale(1) rotate(0)' }, { transform: 'scale(1.25) rotate(-12deg)', offset: 0.4 }, { transform: 'scale(1) rotate(0)' }], { duration: 520, easing: 'cubic-bezier(0.34,1.5,0.64,1)' });
  };
  const choose = (a: 'yes' | 'no', el: HTMLElement) => {
    setAnswer(a);
    f.pick(a === 'yes' ? 1 : 0);
    bounce(el.querySelector('svg') ?? el);
    if (a === 'yes') {
      p.onSubmit?.({ score: 1, reasons: [], comment: '' });
    }
  };
  return (
    <div className="mx-auto max-w-[42rem]">
      {p.article && (
        <Reveal effect="rise">
          <article className="border-b border-border pb-8">
            <h3 className="text-balance text-h3 font-semibold text-foreground">{p.article.title}</h3>
            <p className="mt-4 text-pretty text-[16px] leading-relaxed text-muted-foreground">{p.article.text}</p>
            {p.article.updated && <p className="mt-4 text-[13px] text-subtle-foreground">{p.article.updated}</p>}
          </article>
        </Reveal>
      )}
      <Reveal effect="rise" delay={80} className="mt-8 rounded-xl border border-border bg-surface p-5 sm:p-6">
        {f.sent ? (
          <Thanks p={p} t={t} />
        ) : (
          <>
            <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
              <p id={qid} className="text-[16px] font-semibold text-foreground">
                {p.question || t.helpful}
              </p>
              <div role="group" aria-labelledby={qid} className="flex gap-2">
                {(['yes', 'no'] as const).map((a) => {
                  const on = answer === a;
                  return (
                    <button
                      key={a}
                      type="button"
                      aria-pressed={on}
                      onClick={(e) => choose(a, e.currentTarget)}
                      className={cn(
                        'inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-lg border px-5 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none sm:flex-none',
                        on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border bg-card text-foreground hover:border-border-strong',
                      )}
                    >
                      <Icon name={a === 'yes' ? 'thumbs-up' : 'thumbs-down'} size={17} className={on ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground'} />
                      {a === 'yes' ? t.yes : t.no}
                    </button>
                  );
                })}
              </div>
            </div>
            {answer === 'yes' && (
              <p role="status" className="kit-rise mt-4 flex items-center gap-2 text-[14px] text-success-foreground">
                <Icon name="check-circle" size={17} />
                {t.gladTitle}. {t.gladText}
              </p>
            )}
            <form
              noValidate
              onSubmit={(e) => {
                e.preventDefault();
                f.send();
              }}
            >
              <FollowUp p={p} t={t} isLow reasons={f.reasons} setReasons={f.setReasons} comment={f.comment} setComment={f.setComment} open={answer === 'no'} compact />
              <Unfold open={answer === 'no'}>
                <div className="flex justify-end pt-4">
                  <Button type="submit" size="sm" icon="send" className="max-sm:w-full">
                    {t.sendShort}
                  </Button>
                </div>
              </Unfold>
            </form>
          </>
        )}
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* effort                                                              */
/* ------------------------------------------------------------------ */

function Effort({ p, t }: { p: NpsFeedbackProps; t: Texts }) {
  const id = useId();
  const f = useFeedback(p);
  const isLow = f.score !== null && f.score <= 3;
  return (
    <div className="mx-auto grid max-w-[60rem] grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)] lg:items-start lg:gap-14">
      {/* A gauge that tips toward easy as the score rises, with a tick for every point */}
      <div className="mx-auto w-full max-w-[15rem] lg:sticky lg:top-24 lg:max-w-[19rem] lg:pt-10">
        <div className="relative">
          <svg viewBox="0 0 200 112" className="w-full overflow-visible" aria-hidden>
            <path d="M20 100 A80 80 0 0 1 180 100" fill="none" stroke="color-mix(in oklab, var(--foreground) 10%, transparent)" strokeWidth="12" strokeLinecap="round" />
            <path d="M20 100 A80 80 0 0 1 180 100" fill="none" stroke="var(--primary)" strokeWidth="12" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={f.score === null ? 1 : round1(1 - f.score / 7)} strokeOpacity={f.score === null ? 0 : 1} className="transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" />
            {Array.from({ length: 7 }, (_, i) => {
              const a = Math.PI - ((i + 1) / 7) * Math.PI;
              const on = f.score !== null && i + 1 <= f.score;
              return <circle key={i} cx={round1(100 + 64 * Math.cos(a))} cy={round1(100 - 64 * Math.sin(a))} r="2.2" className={cn('transition-colors duration-500', on ? 'fill-primary' : 'fill-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')} />;
            })}
          </svg>
          <p aria-hidden className="absolute inset-x-0 bottom-0 text-center">
            <span key={f.score ?? 'none'} className={cn('inline-block text-[44px] font-semibold leading-none tabular-nums tracking-[-0.03em] text-foreground', f.score !== null && 'motion-safe:animate-[kit-rise_0.45s_var(--ease-kit)]')}>
              {f.score ?? 0}
            </span>
            <span className="ms-1 text-[15px] font-medium text-muted-foreground">/ 7</span>
          </p>
        </div>
        <p aria-live="polite" className="mt-3 text-center text-[13px] text-muted-foreground">
          {f.score === null ? t.notRated : f.score <= 3 ? p.low : f.score >= 5 ? p.high : t.inBetween}
        </p>
      </div>
      <Reveal effect="rise" className="kit-card min-w-0 p-6 sm:p-8">
        {f.sent ? (
          <Thanks p={p} t={t} onChange={() => f.setSent(false)} />
        ) : (
          <form
            noValidate
            onSubmit={(e) => {
              e.preventDefault();
              if (!f.send()) document.getElementById(id)?.focus();
            }}
          >
            <ScalePicker
              id={id}
              legend={<span className="text-[18px] font-semibold tracking-[-0.02em]">{p.question}</span>}
              points={Array.from({ length: 7 }, (_, i) => ({ value: String(i + 1), label: t.score.replace('{n}', String(i + 1)), short: String(i + 1) }))}
              value={f.score !== null ? String(f.score) : undefined}
              onChange={(v) => f.pick(Number(v))}
              low={p.low}
              high={p.high}
              size="lg"
              error={f.error ? t.pickScore : undefined}
            />
            <FollowUp p={p} t={t} isLow={isLow} reasons={f.reasons} setReasons={f.setReasons} comment={f.comment} setComment={f.setComment} open={f.score !== null} />
            <div className="mt-7 border-t border-border pt-6">
              <Button type="submit" icon="send" className="w-full sm:w-auto">
                {t.send}
              </Button>
            </div>
          </form>
        )}
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* support-rating                                                      */
/* ------------------------------------------------------------------ */

function Stars({ id, value, onChange, t, error }: { id: string; value: number | null; onChange: (n: number) => void; t: Texts; error?: boolean }) {
  const [hover, setHover] = useState<number | null>(null);
  const shown = hover ?? value ?? -1;
  return (
    <div className="flex gap-1" onPointerLeave={() => setHover(null)}>
      {Array.from({ length: 5 }, (_, i) => (
        <label key={i} htmlFor={`${id}-${i}`} onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(i)} className="group/star grid size-11 cursor-pointer place-items-center rounded-lg has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]">
          <input id={`${id}-${i}`} type="radio" name={`${id}-stars`} value={i + 1} checked={value === i} onChange={() => onChange(i)} aria-label={t.stars.replace('{n}', String(i + 1))} aria-invalid={error || undefined} className="sr-only" />
          <svg viewBox="0 0 24 24" width={30} height={30} aria-hidden className={cn('transition-[scale,color] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', i <= shown ? 'scale-110 text-primary' : 'text-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')} style={{ transitionDelay: hover === null && value !== null ? `${i * 40}ms` : '0ms' }}>
            <path d="M12 3.2l2.6 5.5 6 .8-4.4 4.1 1.1 5.9L12 16.6l-5.3 2.9 1.1-5.9-4.4-4.1 6-.8z" fill="currentColor" stroke="currentColor" strokeWidth="1" strokeLinejoin="round" />
          </svg>
        </label>
      ))}
    </div>
  );
}

function SupportRating({ p, t }: { p: NpsFeedbackProps; t: Texts }) {
  const id = useId();
  const f = useFeedback(p);
  const agent = p.agent ?? { name: '' };
  const initials = agent.initials ?? agent.name.split(' ').map((s) => s[0]).join('').slice(0, 2);
  const isLow = f.score !== null && f.score <= 2;
  return (
    <div className="mx-auto max-w-[30rem]">
      <Reveal effect="scale" className="kit-card overflow-hidden">
        <div className="flex items-center gap-3 border-b border-border bg-surface px-5 py-4">
          <span className="relative grid size-10 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[14px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
            {initials}
            <span className="absolute -bottom-0.5 -end-0.5 size-3 rounded-full border-2 border-surface bg-success" />
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate text-[15px] font-semibold text-foreground">{agent.name}</p>
            {agent.role && <p className="text-[13px] leading-snug text-muted-foreground">{agent.role}</p>}
          </div>
          <span className="rounded-full border border-border px-2.5 py-1 text-[12px] text-muted-foreground">{t.closed}</span>
        </div>
        {p.messages && p.messages.length > 0 && (
          <ol aria-label={t.conversation} className="grid gap-2 px-5 pt-5">
            {p.messages.map((m, i) => (
              <li key={i} className={cn('kit-rise max-w-[85%] rounded-2xl px-3.5 py-2.5 text-[14px] leading-snug', m.from === 'agent' ? 'justify-self-start rounded-bl-md bg-surface-2 text-foreground' : 'justify-self-end rounded-br-md bg-primary text-primary-foreground')} style={{ '--kit-delay': `${150 + i * 160}ms` } as React.CSSProperties}>
                <span className="sr-only">{m.from === 'agent' ? agent.name : t.you}: </span>
                {m.text}
              </li>
            ))}
          </ol>
        )}
        <div className="p-5">
          <div className="rounded-xl border border-border bg-background p-4 sm:p-5">
            {f.sent ? (
              <Thanks p={p} t={t} />
            ) : (
              <form
                noValidate
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!f.send()) document.getElementById(`${id}-star-0`)?.focus();
                }}
              >
                <fieldset className="min-w-0" aria-describedby={f.error ? `${id}-err` : undefined}>
                  <legend className="text-[15px] font-semibold text-foreground">{p.question}</legend>
                  <div className="-ms-1.5 mt-2">
                    <Stars id={`${id}-star`} value={f.score} onChange={f.pick} t={t} error={f.error} />
                  </div>
                  {f.error && (
                    <p id={`${id}-err`} role="alert" className="kit-rise mt-1 text-[13px] font-medium text-destructive">
                      {t.pickScore}
                    </p>
                  )}
                </fieldset>
                <FollowUp p={p} t={t} isLow={isLow} reasons={f.reasons} setReasons={f.setReasons} comment={f.comment} setComment={f.setComment} open={f.score !== null} compact />
                <Button type="submit" icon="send" className="mt-5 w-full">
                  {t.send}
                </Button>
              </form>
            )}
          </div>
        </div>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * A quick rating with a follow-up that fits the answer.
 *   <NpsFeedback variant="nps" question="How likely are you to recommend us?" low="Not likely" high="Very likely" lowFollowUp="What would make it a 10?" />
 */
export function NpsFeedback(props: NpsFeedbackProps) {
  const { variant = 'nps', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const head = <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />;
  return (
    <Section className={className}>
      {head}
      {variant === 'nps' && <Nps p={props} t={t} />}
      {variant === 'csat' && <Csat p={props} t={t} />}
      {variant === 'thumbs' && <Thumbs p={props} t={t} />}
      {variant === 'effort' && <Effort p={props} t={t} />}
      {variant === 'support-rating' && <SupportRating p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich NPS und Bewertungen?

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

Welche Varianten hat NPS und Bewertungen?

5 Varianten, Weiterempfehlung, Zufriedenheit mit Gesichtern, War das hilfreich, Aufwand und Support-Bewertung. Du wählst sie mit der Prop variant, zum Beispiel variant="csat".

Kann ich die Texte von NPS und Bewertungen übersetzen?

Ja. NPS und Bewertungen bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „Feedback senden“, „Senden“ und „Möchtest du uns noch etwas sagen?“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent NPS und Bewertungen einbauen?

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

Kann ich NPS und Bewertungen ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert