Survey 1: NPS feedback
A three-question feedback card: the 0 to 10 recommend question as big buttons, a follow-up that changes with the answer, an optional comment, a progress bar, Back that keeps answers, and a failed send that keeps what was typed.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Survey 1. https://ui.ballmac.com/blocks/survey-1
"use client"
import * as React from "react"
import { ArrowLeft, ArrowRight, CircleAlert, Heart, Loader2, PartyPopper } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { Textarea } from "@/components/ballmac/textarea"
import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource"
import { cn } from "@/lib/utils"
type Survey1Result = { score: number; reasons: string[]; comment: string; mayContact: boolean }
type Survey1Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> &
FormStateProps & {
/** The product the question is about. */
product?: string
/** Reasons offered to people who gave a low or middling score. */
lowReasons?: string[]
/** Reasons offered to people who gave a high score. */
highReasons?: string[]
/** The question shown first (0 to 2). */
defaultStep?: 0 | 1 | 2
/** A score chosen at first. */
defaultScore?: number
/** Called with the answers. Return a promise; reject to show an error and keep what was typed. */
onSubmit?: (result: Survey1Result) => void | Promise<void>
}
const low = ["It is hard to learn", "Too slow", "Missing something I need", "Too expensive", "Bugs or crashes", "Support was slow"]
const high = ["Easy to use", "Saves me time", "Looks great", "Great support", "Good value", "Does exactly what I need"]
// What the score means to the person, in their words rather than a bucket name.
const mood = (n: number) => (n <= 6 ? "We missed the mark" : n <= 8 ? "Good, but not great" : "You are a fan")
/**
* A short feedback survey on one card: the 0 to 10 question as big buttons, then a follow-up that changes with the answer (what went wrong,
* or what is working), then an optional comment. A progress bar says where you are, Back keeps your answers, and a failed send keeps what
* you typed.
*/
function Survey1({
product = "Acme",
lowReasons = low,
highReasons = high,
defaultStep = 0,
defaultScore,
onSubmit,
state: stateProp,
errorState,
className,
...props
}: Survey1Props) {
const id = React.useId()
const [step, setStep] = React.useState<number>(defaultStep)
const [score, setScore] = React.useState<number | null>(defaultScore ?? null)
const [reasons, setReasons] = React.useState<string[]>([])
const [comment, setComment] = React.useState("")
const [mayContact, setMayContact] = React.useState(false)
const [internal, setInternal] = React.useState<"idle" | "sending" | "done" | "error">("idle")
const state = formPhase(stateProp) ?? internal
const headingRef = React.useRef<HTMLHeadingElement>(null)
const first = React.useRef(true)
const happy = (score ?? 0) >= 9
const choices = happy ? highReasons : lowReasons
// Move focus to the new step's heading so a keyboard or screen-reader user is not left on a button that is gone.
React.useEffect(() => {
if (first.current) first.current = false
else headingRef.current?.focus()
}, [step, state])
const pickScore = (n: number) => {
if (score !== null && (score >= 9) !== (n >= 9)) setReasons([])
setScore(n)
}
const toggle = (r: string) => setReasons((rs) => (rs.includes(r) ? rs.filter((x) => x !== r) : [...rs, r]))
async function send() {
if (score === null) return
setInternal("sending")
try {
if (onSubmit) await onSubmit({ score, reasons, comment: comment.trim(), mayContact })
else await new Promise((r) => setTimeout(r, 800))
setInternal("done")
} catch {
setInternal("error")
}
}
const sending = state === "sending"
const progress = state === "done" ? 100 : ((step + 1) / 3) * 100
return (
<section data-slot="survey-1" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn("mx-auto w-full max-w-2xl px-4 py-12 sm:px-6 sm:py-20", className)} {...props}>
<div className="bg-card overflow-hidden rounded-3xl border shadow-sm">
<div role="progressbar" aria-label="Survey progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress)} className="bg-muted h-1.5">
<div className="bg-foreground h-full transition-[width] duration-500 ease-out motion-reduce:transition-none" style={{ width: `${progress}%` }} />
</div>
<div className="p-6 sm:p-10">
{state === "done" ? (
<div role="status" className="py-6 text-center">
<span aria-hidden="true" className="bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full">
{happy ? <Heart className="size-7" /> : <PartyPopper className="size-7" />}
</span>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-6 text-2xl font-semibold tracking-tight outline-none">
Thank you
</h2>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-pretty">
{happy ? `That means a lot to the whole ${product} team.` : `Someone on the ${product} team will read this, and ${mayContact ? "may write back." : "it will shape what we fix next."}`}
</p>
</div>
) : (
<>
<p className="text-muted-foreground text-sm font-medium tabular-nums">Question {step + 1} of 3</p>
{step === 0 ? (
<div>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl">
How likely are you to recommend {product} to a friend or colleague?
</h2>
<div role="radiogroup" aria-labelledby={`${id}-h`} className="mt-8 grid grid-cols-6 gap-2 sm:grid-cols-11">
{Array.from({ length: 11 }, (_, n) => (
<button
key={n}
type="button"
role="radio"
aria-checked={score === n}
aria-label={`${n}${n === 0 ? ", not at all likely" : n === 10 ? ", extremely likely" : ""}`}
onClick={() => pickScore(n)}
onKeyDown={(e) => {
const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0
if (!d) return
e.preventDefault()
const next = Math.max(0, Math.min(10, (score ?? n) + d))
pickScore(next)
;(e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus()
}}
tabIndex={score === n || (score === null && n === 0) ? 0 : -1}
className={cn(
"focus-visible:ring-ring/50 aspect-square rounded-xl border text-lg font-semibold tabular-nums outline-none transition-all duration-150 focus-visible:ring-[3px] motion-reduce:transition-none sm:aspect-[3/4]",
score === n ? "bg-foreground text-background border-foreground scale-105 shadow-md" : "hover:bg-accent hover:border-foreground/30"
)}
>
{n}
</button>
))}
</div>
<div aria-hidden="true" className="text-muted-foreground mt-3 flex justify-between text-xs">
<span>Not at all likely</span>
<span>Extremely likely</span>
</div>
<p aria-live="polite" className={cn("mt-6 h-6 text-center text-sm font-medium transition-opacity motion-reduce:transition-none", score === null && "opacity-0")}>
{score === null ? "" : mood(score)}
</p>
</div>
) : null}
{step === 1 ? (
<div>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl">
{happy ? "What do you like most?" : "What got in your way?"}
</h2>
<p className="text-muted-foreground mt-2 text-sm">Choose any that fit.</p>
<div role="group" aria-labelledby={`${id}-h`} className="mt-6 flex flex-wrap gap-2.5">
{choices.map((r) => (
<button
key={r}
type="button"
aria-pressed={reasons.includes(r)}
onClick={() => toggle(r)}
className={cn("focus-visible:ring-ring/50 rounded-full border px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", reasons.includes(r) ? "bg-foreground text-background border-foreground" : "hover:bg-accent")}
>
{r}
</button>
))}
</div>
</div>
) : null}
{step === 2 ? (
<div>
<h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl">
Anything you would like to add?
</h2>
<label htmlFor={`${id}-c`} className="text-muted-foreground mt-2 block text-sm">
Optional. A sentence is plenty.
</label>
<Textarea id={`${id}-c`} value={comment} onChange={(e) => setComment(e.target.value)} rows={5} maxLength={1000} disabled={sending} className="mt-4" />
<label className="mt-5 flex cursor-pointer items-start gap-3 text-sm">
<input type="checkbox" checked={mayContact} onChange={(e) => setMayContact(e.target.checked)} disabled={sending} className="accent-foreground mt-0.5 size-4" />
<span>You may contact me about this answer.</span>
</label>
{state === "error" ? (
<p role="alert" className="text-destructive mt-5 flex items-start gap-2 text-sm">
<CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{errorState?.description ?? "We could not send your answers. Nothing you typed was lost, so try again."}
</p>
) : null}
</div>
) : null}
<div className="mt-10 flex items-center justify-between gap-3">
<Button variant="ghost" onClick={() => setStep((s) => s - 1)} disabled={step === 0 || sending} className={cn(step === 0 && "invisible")}>
<ArrowLeft aria-hidden="true" className="rtl:rotate-180" />
Back
</Button>
{step < 2 ? (
<Button onClick={() => setStep((s) => s + 1)} disabled={step === 0 && score === null} aria-describedby={step === 0 && score === null ? `${id}-need` : undefined}>
Next
<ArrowRight aria-hidden="true" className="rtl:rotate-180" />
</Button>
) : (
<Button onClick={send} disabled={sending}>
{sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
{sending ? "Sending" : state === "error" ? "Try again" : "Send"}
</Button>
)}
</div>
{step === 0 && score === null ? (
<p id={`${id}-need`} className="sr-only">
Choose a number to continue.
</p>
) : null}
</>
)}
</div>
</div>
</section>
)
}
export { Survey1, type Survey1Props, type Survey1Result }Installation
$ pnpm dlx shadcn@latest add @ballmac/survey-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/textarea @ballmac/use-resourceCopy the source into your project.
components/ballmac/blocks/survey-1/survey-1.tsx// Ballmac UI: Survey 1. https://ui.ballmac.com/blocks/survey-1 "use client" import * as React from "react" import { ArrowLeft, ArrowRight, CircleAlert, Heart, Loader2, PartyPopper } from "lucide-react" import { Button } from "@/components/ballmac/button" import { Textarea } from "@/components/ballmac/textarea" import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource" import { cn } from "@/lib/utils" type Survey1Result = { score: number; reasons: string[]; comment: string; mayContact: boolean } type Survey1Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> & FormStateProps & { /** The product the question is about. */ product?: string /** Reasons offered to people who gave a low or middling score. */ lowReasons?: string[] /** Reasons offered to people who gave a high score. */ highReasons?: string[] /** The question shown first (0 to 2). */ defaultStep?: 0 | 1 | 2 /** A score chosen at first. */ defaultScore?: number /** Called with the answers. Return a promise; reject to show an error and keep what was typed. */ onSubmit?: (result: Survey1Result) => void | Promise<void> } const low = ["It is hard to learn", "Too slow", "Missing something I need", "Too expensive", "Bugs or crashes", "Support was slow"] const high = ["Easy to use", "Saves me time", "Looks great", "Great support", "Good value", "Does exactly what I need"] // What the score means to the person, in their words rather than a bucket name. const mood = (n: number) => (n <= 6 ? "We missed the mark" : n <= 8 ? "Good, but not great" : "You are a fan") /** * A short feedback survey on one card: the 0 to 10 question as big buttons, then a follow-up that changes with the answer (what went wrong, * or what is working), then an optional comment. A progress bar says where you are, Back keeps your answers, and a failed send keeps what * you typed. */ function Survey1({ product = "Acme", lowReasons = low, highReasons = high, defaultStep = 0, defaultScore, onSubmit, state: stateProp, errorState, className, ...props }: Survey1Props) { const id = React.useId() const [step, setStep] = React.useState<number>(defaultStep) const [score, setScore] = React.useState<number | null>(defaultScore ?? null) const [reasons, setReasons] = React.useState<string[]>([]) const [comment, setComment] = React.useState("") const [mayContact, setMayContact] = React.useState(false) const [internal, setInternal] = React.useState<"idle" | "sending" | "done" | "error">("idle") const state = formPhase(stateProp) ?? internal const headingRef = React.useRef<HTMLHeadingElement>(null) const first = React.useRef(true) const happy = (score ?? 0) >= 9 const choices = happy ? highReasons : lowReasons // Move focus to the new step's heading so a keyboard or screen-reader user is not left on a button that is gone. React.useEffect(() => { if (first.current) first.current = false else headingRef.current?.focus() }, [step, state]) const pickScore = (n: number) => { if (score !== null && (score >= 9) !== (n >= 9)) setReasons([]) setScore(n) } const toggle = (r: string) => setReasons((rs) => (rs.includes(r) ? rs.filter((x) => x !== r) : [...rs, r])) async function send() { if (score === null) return setInternal("sending") try { if (onSubmit) await onSubmit({ score, reasons, comment: comment.trim(), mayContact }) else await new Promise((r) => setTimeout(r, 800)) setInternal("done") } catch { setInternal("error") } } const sending = state === "sending" const progress = state === "done" ? 100 : ((step + 1) / 3) * 100 return ( <section data-slot="survey-1" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn("mx-auto w-full max-w-2xl px-4 py-12 sm:px-6 sm:py-20", className)} {...props}> <div className="bg-card overflow-hidden rounded-3xl border shadow-sm"> <div role="progressbar" aria-label="Survey progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress)} className="bg-muted h-1.5"> <div className="bg-foreground h-full transition-[width] duration-500 ease-out motion-reduce:transition-none" style={{ width: `${progress}%` }} /> </div> <div className="p-6 sm:p-10"> {state === "done" ? ( <div role="status" className="py-6 text-center"> <span aria-hidden="true" className="bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full"> {happy ? <Heart className="size-7" /> : <PartyPopper className="size-7" />} </span> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-6 text-2xl font-semibold tracking-tight outline-none"> Thank you </h2> <p className="text-muted-foreground mx-auto mt-2 max-w-sm text-pretty"> {happy ? `That means a lot to the whole ${product} team.` : `Someone on the ${product} team will read this, and ${mayContact ? "may write back." : "it will shape what we fix next."}`} </p> </div> ) : ( <> <p className="text-muted-foreground text-sm font-medium tabular-nums">Question {step + 1} of 3</p> {step === 0 ? ( <div> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl"> How likely are you to recommend {product} to a friend or colleague? </h2> <div role="radiogroup" aria-labelledby={`${id}-h`} className="mt-8 grid grid-cols-6 gap-2 sm:grid-cols-11"> {Array.from({ length: 11 }, (_, n) => ( <button key={n} type="button" role="radio" aria-checked={score === n} aria-label={`${n}${n === 0 ? ", not at all likely" : n === 10 ? ", extremely likely" : ""}`} onClick={() => pickScore(n)} onKeyDown={(e) => { const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0 if (!d) return e.preventDefault() const next = Math.max(0, Math.min(10, (score ?? n) + d)) pickScore(next) ;(e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus() }} tabIndex={score === n || (score === null && n === 0) ? 0 : -1} className={cn( "focus-visible:ring-ring/50 aspect-square rounded-xl border text-lg font-semibold tabular-nums outline-none transition-all duration-150 focus-visible:ring-[3px] motion-reduce:transition-none sm:aspect-[3/4]", score === n ? "bg-foreground text-background border-foreground scale-105 shadow-md" : "hover:bg-accent hover:border-foreground/30" )} > {n} </button> ))} </div> <div aria-hidden="true" className="text-muted-foreground mt-3 flex justify-between text-xs"> <span>Not at all likely</span> <span>Extremely likely</span> </div> <p aria-live="polite" className={cn("mt-6 h-6 text-center text-sm font-medium transition-opacity motion-reduce:transition-none", score === null && "opacity-0")}> {score === null ? "" : mood(score)} </p> </div> ) : null} {step === 1 ? ( <div> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl"> {happy ? "What do you like most?" : "What got in your way?"} </h2> <p className="text-muted-foreground mt-2 text-sm">Choose any that fit.</p> <div role="group" aria-labelledby={`${id}-h`} className="mt-6 flex flex-wrap gap-2.5"> {choices.map((r) => ( <button key={r} type="button" aria-pressed={reasons.includes(r)} onClick={() => toggle(r)} className={cn("focus-visible:ring-ring/50 rounded-full border px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none", reasons.includes(r) ? "bg-foreground text-background border-foreground" : "hover:bg-accent")} > {r} </button> ))} </div> </div> ) : null} {step === 2 ? ( <div> <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className="mt-2 text-2xl font-semibold tracking-tight text-balance outline-none sm:text-3xl"> Anything you would like to add? </h2> <label htmlFor={`${id}-c`} className="text-muted-foreground mt-2 block text-sm"> Optional. A sentence is plenty. </label> <Textarea id={`${id}-c`} value={comment} onChange={(e) => setComment(e.target.value)} rows={5} maxLength={1000} disabled={sending} className="mt-4" /> <label className="mt-5 flex cursor-pointer items-start gap-3 text-sm"> <input type="checkbox" checked={mayContact} onChange={(e) => setMayContact(e.target.checked)} disabled={sending} className="accent-foreground mt-0.5 size-4" /> <span>You may contact me about this answer.</span> </label> {state === "error" ? ( <p role="alert" className="text-destructive mt-5 flex items-start gap-2 text-sm"> <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> {errorState?.description ?? "We could not send your answers. Nothing you typed was lost, so try again."} </p> ) : null} </div> ) : null} <div className="mt-10 flex items-center justify-between gap-3"> <Button variant="ghost" onClick={() => setStep((s) => s - 1)} disabled={step === 0 || sending} className={cn(step === 0 && "invisible")}> <ArrowLeft aria-hidden="true" className="rtl:rotate-180" /> Back </Button> {step < 2 ? ( <Button onClick={() => setStep((s) => s + 1)} disabled={step === 0 && score === null} aria-describedby={step === 0 && score === null ? `${id}-need` : undefined}> Next <ArrowRight aria-hidden="true" className="rtl:rotate-180" /> </Button> ) : ( <Button onClick={send} disabled={sending}> {sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null} {sending ? "Sending" : state === "error" ? "Try again" : "Send"} </Button> )} </div> {step === 0 && score === null ? ( <p id={`${id}-need`} className="sr-only"> Choose a number to continue. </p> ) : null} </> )} </div> </div> </section> ) } export { Survey1, type Survey1Props, type Survey1Result }Update the import paths to match your project setup.
Usage
"use client"
import { Survey1 } from "@/components/ballmac/blocks/survey-1/survey-1"
export default function Survey1Demo() {
return <Survey1 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Sending
The comment and buttons are disabled, Send shows a spinner and the card is marked busy.
state="submitting"Thank you
The progress bar fills and the card thanks the person; the words change for a fan and for someone who was unhappy.
state="success"Could not send
An alert on the last step saying nothing was lost, with Try again; every answer is still there.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
5 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships submitting, success, error states, each with an example.
Passed: Automated tests
Covered by survey-1.test.tsx.
For information: Footprint
3.8 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
An NPS survey card. <Survey1 product='Acme' lowReasons highReasons onSubmit={({ score, reasons, comment, mayContact }) => Promise} />. Scores 9 and 10 get the high reasons ('What do you like most?'), everything else the low ones. Reject onSubmit to show the error with the answers kept. Pass state to drive it, or leave it out to run by itself. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Survey 1: NPS feedback (@ballmac/survey-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A feedback page linked from an email
- Asking for NPS after onboarding or a milestone
Not for
- A floating widget in the app (use feedback-widget)
- A long, multi-section form
Registry JSON: https://ui.ballmac.com/r/survey-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.