Blocks

NPS Survey

A whole Net Promoter Score round in one block: a keyboard-reachable 0–10 scale, a follow-up question reworded per detractor / passive / promoter bucket, an optional contact-me box, and a one-submission-per-session thank-you.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, LoaderCircle, X } from "lucide-react"

import { cn } from "@/lib/utils"

/** The standard Net Promoter Score split: 0–6 detractor, 7–8 passive, 9–10 promoter. */
export type NpsBucket = "detractor" | "passive" | "promoter"

/** `"asking"` covers both steps — the scale alone, and the scale plus the follow-up. */
export type NpsSurveyStatus = "asking" | "sending" | "error" | "thanks"

/** Why the survey removed itself: the visitor closed it, or it finished. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/nps-survey.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "NpsSurvey" block. Only runtime dependency is
lucide-react (Check, LoaderCircle, X) plus a cn() class merger. No form library, no
animation library, no state machine library — one useState per concern is enough.

Contract
- export type NpsBucket = "detractor" | "passive" | "promoter"
- export type NpsSurveyStatus = "asking" | "sending" | "error" | "thanks"
  ("asking" covers both steps: the bare scale, and the scale plus the follow-up.)
- export type NpsDismissReason = "dismissed" | "submitted"
- export interface NpsSurveyPayload {
    score: number      // integer 0-10, never null: it cannot be submitted without one
    bucket: NpsBucket  // derived, passed along so the host never re-derives it
    comment: string    // trimmed; "" is a legal answer
    contactMe: boolean // forced false when askContact is off, so the payload cannot lie
  }
- export function npsBucket(score: number): NpsBucket — >= 9 promoter, >= 7 passive,
  else detractor. Export it: the host almost always reports on the same split.
- Default + named export of a forwardRef<HTMLElement, NpsSurveyProps> rendering a
  <section>; NpsSurveyProps extends
  Omit<React.HTMLAttributes<HTMLElement>, "onSubmit" | "children">, so the root spreads
  the rest of the native props.
- Required prop: onSubmit(payload: NpsSurveyPayload) => Promise<void>. The block never
  fetches anything itself — resolve to show the thank-you, reject to show a retryable
  error (an Error's own message is printed verbatim).
- onScoreChange?(score, bucket) fires on EVERY score change including arrow-key ones.
- onDismiss?(reason) fires exactly once per mounted survey, never twice.
- Presentation props, all optional, with defaults:
  variant "inline" | "corner" = "inline"; side "right" | "left" = "right" (corner only);
  question (a full sentence by default; "" drops the heading); description;
  label = "Net Promoter Score survey" (names the region when question is "");
  lowLabel = "Not at all likely"; highLabel = "Extremely likely";
  followUp?: Partial<Record<NpsBucket, string>> MERGED over the built-in three, so
  overriding one bucket keeps the other two; commentPlaceholder; commentHint;
  maxLength = 400; askContact = true; contactLabel; contactAck;
  defaultScore: number | null = null; defaultComment = "";
  submitLabel; pendingLabel = "Sending…"; retryLabel = "Try again"; failureMessage;
  thanksTitle; thanks?: Partial<Record<NpsBucket, string>> (merged the same way);
  thanksDuration = 0 ms (0 means the thank-you stays until the host unmounts it);
  dismissible = true; dismissLabel.
- Uncontrolled on purpose: score, comment, contactMe, status and the dismissed flag all
  live inside. The host learns everything through the three callbacks.

Behavior
- Buckets and the rail: 0-6 detractor, 7-8 passive, 9-10 promoter. The scale is an
  11-column grid; the coloured rail underneath is a SECOND grid-cols-11 with spans
  7 / 2 / 2, so each band starts exactly under its first score instead of approximately.
  Nothing picked -> all bands at 40% opacity; a score picked -> its band 100%, the other
  two 20%.
- Score first, comment second. Picking a number fires onScoreChange immediately, so an
  abandoned survey still produces the metric; the comment is a bonus and never a gate.
  Only after a score exists does the follow-up block mount (one frame late, so it can
  fade and slide 4px into place).
- The scale stays live after it is answered. Changing 6 to 9 rewrites the follow-up
  question in place — no re-mount, no focus theft, no trapping someone in the branch
  their first click landed in.
- Follow-up copy is per bucket and is the textarea's real label: detractor gets "what
  went wrong, what should we fix first", passive gets "what would make this a 10",
  promoter gets "what works well enough to mention to someone". One generic
  "any comments?" box earns none of those answers.
- Keyboard on the scale (role=radiogroup, roving tabindex — exactly one Tab stop):
    Arrow Right / Up      +1, clamped at 10 (NEVER cyclic: wrapping 10 round to 0 turns
                          the best answer into the worst on one extra press)
    Arrow Left / Down     -1, clamped at 0
    Home / End            0 / 10
    0-9                   select that score directly
    1 then 0 within 700ms select 10 ("10" has no single key). Outside the window, 0 is
                          zero. Any other scale key closes the window first.
  Selection follows focus, and every one of these paths goes through the same
  chooseScore(next, moveFocus) so the callback and the announcement cannot diverge.
- Keyboard elsewhere: the textarea keeps Enter for newlines; Cmd/Ctrl+Enter submits
  through the same form path as the button.
- Submit is a real <form onSubmit>, so button click, Enter on the button and the
  shortcut are one code path. The one-submission guarantee is a ref (pendingRef) that is
  read AND written synchronously before the first await, plus a sentRef latch after a
  success — state-only guards let a double click in the same tick through both times.
- Rejection is non-destructive: score, comment and checkbox all survive; the reason
  renders in a role=alert; the button relabels to retryLabel; pendingRef is released so
  the retry works; nothing auto-dismisses. Moving the score afterwards clears the stale
  reason (that payload no longer exists) while keeping the typed answers.
- Success latches sentRef even if the survey has already unmounted — the answer did
  reach the host, so nothing may send a second one. State is only written when still
  mounted (a mountedRef set in the effect BODY, not merely cleared in cleanup, or
  StrictMode's mount -> cleanup -> mount leaves it false forever).
- Thank-you panel: a check badge, the per-bucket thanks line, the accepted score echoed
  as "n / 10", and the contact acknowledgement only when the box was actually ticked.
  It replaces the whole form, so it is tabIndex={-1} and TAKES FOCUS when — and only
  when — the visitor was still standing inside that form as it resolved: otherwise the
  control they pressed Enter on unmounts under them and the browser drops focus on
  <body>, which is the same failure the aria-disabled rule below exists to prevent.
  Read the containment at resolve time, not at submit time: someone who clicked away
  while the request was in flight must not have their focus yanked back.
  With thanksDuration > 0 it then dismisses itself with reason "submitted"; the close
  button dismisses with "dismissed". Both go through one dismiss() guarded by a ref, so
  an auto-dismiss racing a click still reports exactly once. After dismissal the block
  renders null.
- Degenerate input is refused quietly, never crashed on: defaultScore that is
  fractional, out of 0..10, null or undefined counts as "nothing picked";
  maxLength non-finite or < 1 falls back to 400 and defaultComment is truncated to the
  resolved cap; thanksDuration non-finite or <= 0 means "do not auto-dismiss";
  question "" drops the heading and hands the region its name via label;
  askContact false removes the checkbox and pins contactMe:false in the payload;
  a rejection that is not an Error becomes its string form, else failureMessage.
- Cleanup: the 700ms ten-window timer, the thanks auto-dismiss timer and both
  requestAnimationFrames (corner entrance, follow-up reveal) are cancelled on unmount
  and on every dependency change. Consumer callbacks are held in latest-refs updated in
  an effect, so inline arrow functions never re-arm a timer.

ARIA contract
- Root <section> is a labelled region: aria-labelledby the question heading, or
  aria-label={label} when question is "".
- The scale is role=radiogroup, labelled by the same heading and aria-describedby the
  endpoint labels; each score is type=button role=radio with aria-checked; only the two
  ends carry an explicit aria-label ("0, Not at all likely" / "10, Extremely likely") so
  the endpoint wording is spoken with the number that means it.
- The bucket-specific follow-up sentence IS the textarea's <label htmlFor>; the hint
  line is aria-describedby. One reading order, one accessible name, nothing duplicated.
- One always-mounted sr-only role=status carries the pending label while sending and the
  new follow-up wording when the bucket CHANGES — not on every step, which would be pure
  chatter. A live region inserted at the same moment as its text is unreliable.
- The rail and the character counter are aria-hidden decoration; the counter's fact is
  already in the hint text.
- The accepted score is announced twice on purpose: "n / 10" as aria-hidden glyphs plus
  an sr-only "You scored n out of 10", because "9 / 10" reads as nothing useful.
- Nothing is ever natively disabled. A control the visitor may be focused on gets
  aria-disabled plus a handler guard; the native attribute blurs focus to <body>
  mid-flight. The textarea uses readOnly (keeps focus, keeps text selectable) and the
  submit button carries aria-busy + aria-disabled while the ref guard does the real work.

Rendering & styling
- Semantic tokens only, zero hex: bg-card / text-card-foreground shell with rounded-2xl
  and border; unselected scores bg-background with hover:bg-accent
  hover:text-accent-foreground; the selected score bg-primary text-primary-foreground;
  helper copy text-muted-foreground; the error box bg-destructive/10 + text-destructive;
  the thank-you panel bg-muted/40 with a bg-primary badge; the checkbox accent-primary.
- The rail is the only place data-series tokens appear: var(--chart-5) detractor,
  var(--chart-3) passive, var(--chart-2) promoter — it re-themes with the project's
  charts instead of inventing a traffic light.
- Numbers are tabular-nums so the scale does not twitch between 8 and 10.
- Inline variant is w-full in the page flow. Corner variant is fixed bottom-4 with
  start-4 / end-4 (logical, so RTL flips it), w-[min(22rem,calc(100vw-2rem))],
  max-h-[calc(100svh-2rem)] + overflow-y-auto so a short viewport can still reach the
  send button, and shadow-lg.
- Motion is decorative only: the corner card fades and lifts 8px one frame after mount,
  the follow-up block fades and drops 4px, the rail cross-fades, the spinner spins. Every
  one of them is motion-reduce:transition-none / motion-reduce:animate-none with the
  end-state classes re-applied, so under prefers-reduced-motion the card simply appears
  and every function stays intact.
- focus-visible:ring-2 ring-ring (with ring-offset on the scale, which sits on a filled
  background) on every interactive element. cn() merges the consumer's className last.
- Expose data-status, data-bucket and data-variant on the root: host CSS and e2e tests
  should not have to read class names.

Customization levers
- Copy is the main lever: followUp and thanks are Partial records merged over the
  defaults, so you can rewrite the detractor question for a support flow and leave the
  other two alone. question="" plus label turns the block into a bare scale for a page
  that already asked the question in its own heading.
- Sub-blocks are opt-out, not forks: askContact=false drops the checkbox (and pins the
  payload field), dismissible=false drops the close button, description and commentHint
  drop when empty. Removing the textarea entirely is a two-line edit — keep
  onScoreChange, since the score is the metric.
- Density: the scale is h-9 / sm:h-10 with gap-1; drop to h-8 and text-xs for a corner
  card in a dense app, or raise gap to gap-2 and the shell to p-6 for a landing page.
  Below ~340px, switch the grid from grid-cols-11 to two rows (grid-cols-6) rather than
  shrinking the hit targets under 32px.
- Colour: the rail is the theming seam. Point all three bands at one token for a
  monochrome brand, or swap to var(--chart-1..5) positions that match your dashboard.
  The selected score follows bg-primary, so it inherits the brand for free.
- Motion strength: duration-200 ease-out throughout; raise to 300 for the corner
  entrance if it enters from a page edge, or delete the entered/followUpIn state pair
  entirely for a zero-motion build — nothing else depends on it.
- Placement: variant="corner" + side is for an app shell; inline is for a settings page,
  a post-checkout page or an email-linked landing page. thanksDuration is the auto-close
  dial — keep it 0 for inline (a page block that vanishes is disorienting) and 2000-3000
  for corner.
- Timing: TEN_WINDOW_MS (700) is the only magic number worth touching; raise it for a
  slower-typing audience, but stay under ~1s or "0" will surprise people who meant zero.

Concepts

  • Score-first collection — the number is the whole NPS metric, so it is reported the instant it is picked rather than at submit time; a visitor who picks 4 and walks away has still told you what you needed, and the comment box is upside.
  • Bucket-conditional follow-up — 0-6 / 7-8 / 9-10 choose which question gets asked, and the question doubles as the field's accessible label. Re-scoring across a boundary rewrites it in place, so nobody is stuck answering the wrong question because of a mis-click.
  • Synchronous one-shot lock — one submission per mounted survey is enforced by a ref read and written before the first await; a state flag would be stale for the second press in the same tick and two identical answers would reach the collector.
  • Non-destructive rejection — a failed send keeps the score, the typed comment and the checkbox, prints the real reason in an alert, relabels the button to "Try again" and never auto-dismisses, because the one thing worse than a lost answer is a lost answer the visitor watched disappear.
  • aria-disabled over native disabled — while the request is in flight the controls announce as disabled but stay focusable; the native attribute would blur the keyboard user to the document body the moment they pressed Enter, and the handler guards are what actually block the work.
  • Two dismiss reasons, one shot — "dismissed" (closed by hand) and "submitted" (auto-close after the thank-you) travel through a single ref-guarded dismiss(), so an auto-close racing a click still reports exactly once and the host can tell a refusal from a completion.

On This Page