# 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/survey-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/survey-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/survey-1-light-c71e33a59ee9ebb7.png
- Registry item: https://ui.ballmac.com/r/survey-1.json
- Categories: blocks, survey
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/survey-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/textarea.json`, `https://ui.ballmac.com/r/use-resource.json`

## Files

### `registry/ballmac/components/blocks/survey-1/survey-1.tsx`

```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 }
```
