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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/nps-survey.jsonPrompt
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.
Status Page
A public status page whose overall banner is derived from the worst service state and open incident, with per-service 90-day uptime strips, timestamped incident updates and upcoming maintenance windows.
Invite Team
A four-state invite panel: a paste-a-list email field with per-address reasons, a role per invite, a seat meter that blocks over-allocation, undoable revoke and a copyable join link.