Skip to content
UI Kit
Accent color

NPS and ratings

One quick rating and a follow-up that fits the answer. A low score asks what was missing, a high one asks what people love, both with tags to tap and room for a comment.

One of 3 components in Surveys and feedback

5 variants

Variants

Net promoter score

The classic 0 to 10 question as tiles that rise in one after another. On phones the scale breaks into two rows, so every tile stays a full tap target. The follow-up opens once a score is chosen.

Open in Studio

Satisfaction faces

Five drawn faces from very unhappy to delighted, the mouth bends with the mood. The chosen face grows with a spring and takes the accent, the follow-up asks what fits the mood.

Open in Studio

Was this helpful

Two buttons at the end of an article. Yes thanks right away, no opens the reasons and a comment with a soft height. The thumb gives a small nod when pressed.

Open in Studio

Customer effort

How easy something was, on seven points. A gauge beside the card tips toward easy as the score rises, the chosen tile slides along the scale and arrow keys move it.

Open in Studio

Support rating

The closing card of a support chat with the person who helped and the last messages. Stars light up under the pointer and settle one after another when chosen.

Open in Studio

Details

What NPS and ratings brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/nps-feedback.tsx
Brings along
utilskitbuttonicontextarearevealsectionform-flow
npm packages
None
Languages
English and German, 21 texts of its own

Props

The NpsFeedbackProps interface from components/ui/nps-feedback.tsx, with the comments from the code.

  • variantoptional
    NpsFeedbackVariant

    npscsatthumbseffortsupport-rating

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • questionrequired
    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

Built-in texts

NPS and ratings writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 21 texts
sendEnglish Send feedbackGerman Feedback senden
sendShortEnglish SendGerman Senden
commentEnglish Anything else you want to tell us?German Möchtest du uns noch etwas sagen?
reasonsEnglish What was it mostly about?German Worum ging es vor allem?
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
helpfulEnglish Was this page helpful?German War diese Seite hilfreich?
gladTitleEnglish Glad it helpedGerman Schön, dass es geholfen hat
gladTextEnglish Thank you for letting us know.German Danke für deine Rückmeldung.
thanksTitleEnglish Thank you, that helps usGerman Danke, das hilft uns
thanksTextEnglish We read every answer and follow up when we can do better.German Wir lesen jede Antwort und melden uns, wenn wir etwas besser machen können.
changeEnglish Change ratingGerman Bewertung ändern
scoreEnglish Score {n}German Wert {n}
pickScoreEnglish Please choose a score first, then we can take your comment.German Bitte wähle zuerst einen Wert, dann nehmen wir deinen Kommentar gern auf.
ratedEnglish You rated {n}German Du hast {n} gewählt
notRatedEnglish Not rated yetGerman Noch nicht bewertet
inBetweenEnglish In betweenGerman Dazwischen
starsEnglish {n} of 5 starsGerman {n} von 5 Sternen
conversationEnglish ConversationGerman Unterhaltung
closedEnglish Chat closedGerman Chat beendet
youEnglish YouGerman Du

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles alert, group 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. 3 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, 583 lines
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>
  );
}

Frequent questions

How do I install NPS and ratings?

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

Which variants does NPS and ratings have?

5 variants, Net promoter score, Satisfaction faces, Was this helpful, Customer effort and Support rating. You pick one with the variant prop, for example variant="csat".

Can I translate the texts of NPS and ratings?

Yes. NPS and ratings carries 21 texts of its own in English and German, such as “Send feedback”, “Send” and “Anything else you want to tell us?”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with NPS and ratings?

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

Can I adjust NPS and ratings without code?

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

Often searched

Jump to

Copied to the clipboard