{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback-scale",
  "type": "registry:ui",
  "title": "Feedback Scale",
  "description": "A five-step satisfaction scale with drawn faces that morph as you hover, a follow-up comment and a thank-you state.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/feedback-scale.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\";\n\nexport interface FeedbackSubmission {\n  /** 1 (most negative) to 5 (most positive). */\n  score: number;\n  /** The step's label, e.g. \"Happy\". */\n  label: string;\n  comment: string;\n}\n\nexport interface FeedbackScaleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"onSubmit\" | \"defaultValue\"> {\n  /** Controlled score (1–5), or null for no answer. */\n  value?: number | null;\n  /** Initial score when uncontrolled. */\n  defaultValue?: number | null;\n  onChange?: (score: number) => void;\n  /** Called with the score, its label and the comment when the form is sent. */\n  onSubmit?: (feedback: FeedbackSubmission) => void;\n  /** The question; also the radiogroup's accessible name. */\n  question?: string;\n  /** Five labels, most negative first. */\n  labels?: [string, string, string, string, string];\n  /** Show each step's label under its face. */\n  showLabels?: boolean;\n  /** Large face beside the question that morphs towards the hovered or chosen step. */\n  showPreview?: boolean;\n  /** Reveal a comment field once a score is chosen. */\n  showComment?: boolean;\n  /** Label of the comment field. */\n  commentLabel?: string;\n  /** Text of the submit button. */\n  submitLabel?: string;\n  /** Message on the thank-you screen. */\n  thanksMessage?: string;\n  /** Face size. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Spring for the face morph (ignored with reduced motion). */\n  spring?: { stiffness: number; damping: number; mass: number };\n  /** Focus rings and the submit button. */\n  accent?: string;\n  /** Name for the radios and the textarea, so the answer submits with a surrounding form. */\n  name?: string;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ---------------------------------- faces ---------------------------------- */\n\n// Warm-negative to positive. Dark facial features stay legible on every one of these.\nconst STEP_COLORS = [\"#EF5A4C\", \"#F58A4B\", \"#F2C14E\", \"#9BCB63\", \"#3DBE84\"];\nconst STOPS = [0, 0.25, 0.5, 0.75, 1];\nconst DEFAULT_LABELS: [string, string, string, string, string] = [\"Very unhappy\", \"Unhappy\", \"Neutral\", \"Happy\", \"Very happy\"];\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/*\n * Every expression is a function of one number, mood m ∈ [0, 1] (0 = very unhappy).\n * The face is drawn in a 48×48 box; paths keep the same commands for every m, so they interpolate.\n */\nfunction mouthPath(m: number) {\n  const width = lerp(14, 20, m);\n  const y = lerp(33, 30.5, m);\n  const curve = lerp(-6.5, 7, m);\n  const open = clamp01((m - 0.7) / 0.3) * 5.5; // the widest smile opens\n  const x1 = r2(24 - width / 2);\n  const x2 = r2(24 + width / 2);\n  return `M${x1} ${r2(y)} Q24 ${r2(y + curve - open * 0.5)} ${x2} ${r2(y)} Q24 ${r2(y + curve + open)} ${x1} ${r2(y)} Z`;\n}\n\nfunction eyeRy(m: number) {\n  // Happy faces squint; very unhappy eyes droop a little.\n  return r2(3.1 - clamp01((m - 0.72) / 0.28) * 1.3 - clamp01((0.2 - m) / 0.2) * 0.4);\n}\n\nfunction browPath(m: number, side: -1 | 1) {\n  // Worry lifts the inner ends; neutral and happy faces have flat, faint brows.\n  const worry = clamp01((0.45 - m) / 0.45);\n  const outer = 24 + side * 11;\n  const inner = 24 + side * 4.5;\n  const y = 13.5 - clamp01((m - 0.75) / 0.25) * 1;\n  return `M${r2(outer)} ${r2(y + worry * 0.5)} L${r2(inner)} ${r2(y - worry * 3.2)}`;\n}\n\nconst browOpacity = (m: number) => r2(0.25 + clamp01((0.45 - m) / 0.45) * 0.75);\nconst cheekOpacity = (m: number) => r2(clamp01((m - 0.6) / 0.4) * 0.45);\n\n/** A static face for one step. */\nfunction Face({ mood, className = \"\" }: { mood: number; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 48 48\" aria-hidden=\"true\" className={className}>\n      <circle cx=\"24\" cy=\"24\" r=\"21.5\" className=\"fill-[var(--fs-face)] stroke-[var(--fs-line)] transition-[fill,stroke] duration-200\" strokeWidth=\"1.5\" />\n      <circle cx=\"12.5\" cy=\"29\" r=\"3\" style={{ fill: \"var(--fs-cheek, #ffffff)\" }} opacity={cheekOpacity(mood)} />\n      <circle cx=\"35.5\" cy=\"29\" r=\"3\" style={{ fill: \"var(--fs-cheek, #ffffff)\" }} opacity={cheekOpacity(mood)} />\n      <g className=\"fill-[var(--fs-ink)] stroke-[var(--fs-ink)] transition-[fill,stroke] duration-200\">\n        <path d={browPath(mood, -1)} fill=\"none\" strokeWidth=\"1.8\" strokeLinecap=\"round\" opacity={browOpacity(mood)} />\n        <path d={browPath(mood, 1)} fill=\"none\" strokeWidth=\"1.8\" strokeLinecap=\"round\" opacity={browOpacity(mood)} />\n        <ellipse cx=\"17.5\" cy=\"20.5\" rx=\"2.4\" ry={eyeRy(mood)} stroke=\"none\" />\n        <ellipse cx=\"30.5\" cy=\"20.5\" rx=\"2.4\" ry={eyeRy(mood)} stroke=\"none\" />\n        <path d={mouthPath(mood)} strokeWidth=\"1.9\" strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n      </g>\n    </svg>\n  );\n}\n\n/** The large face: every feature is a motion value derived from one animated mood. */\nfunction MorphFace({ mood, idle, className = \"\" }: { mood: MotionValue<number>; idle: boolean; className?: string }) {\n  const fill = useTransform(mood, STOPS, STEP_COLORS);\n  const mouth = useTransform(mood, mouthPath);\n  const browL = useTransform(mood, (m) => browPath(m, -1));\n  const browR = useTransform(mood, (m) => browPath(m, 1));\n  const browO = useTransform(mood, browOpacity);\n  const eye = useTransform(mood, eyeRy);\n  const cheek = useTransform(mood, cheekOpacity);\n  return (\n    <svg\n      viewBox=\"0 0 48 48\"\n      aria-hidden=\"true\"\n      className={`transition-[filter,opacity] duration-300 motion-reduce:transition-none ${idle ? \"opacity-70 grayscale\" : \"\"} ${className}`}\n    >\n      <motion.circle cx=\"24\" cy=\"24\" r=\"22\" style={{ fill }} />\n      <motion.circle cx=\"12.5\" cy=\"29\" r=\"3\" fill=\"#ffffff\" style={{ opacity: cheek }} />\n      <motion.circle cx=\"35.5\" cy=\"29\" r=\"3\" fill=\"#ffffff\" style={{ opacity: cheek }} />\n      <g fill=\"#18181b\" stroke=\"#18181b\">\n        <motion.path d={browL} fill=\"none\" strokeWidth=\"1.8\" strokeLinecap=\"round\" style={{ opacity: browO }} />\n        <motion.path d={browR} fill=\"none\" strokeWidth=\"1.8\" strokeLinecap=\"round\" style={{ opacity: browO }} />\n        <motion.ellipse cx=\"17.5\" cy=\"20.5\" rx=\"2.4\" ry={eye} stroke=\"none\" />\n        <motion.ellipse cx=\"30.5\" cy=\"20.5\" rx=\"2.4\" ry={eye} stroke=\"none\" />\n        <motion.path d={mouth} strokeWidth=\"1.9\" strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n      </g>\n    </svg>\n  );\n}\n\nconst SIZES = {\n  sm: { face: \"size-9\", hero: \"size-12\", label: \"text-[10px]\" },\n  md: { face: \"size-11\", hero: \"size-16\", label: \"text-[11px]\" },\n  lg: { face: \"size-14\", hero: \"size-20\", label: \"text-[12px]\" },\n};\n\nconst moodOf = (score: number) => (score - 1) / 4;\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * A five-step satisfaction scale with drawn faces. A large face morphs towards the hovered or\n * chosen step; choosing reveals an optional comment field, and sending shows a thank-you state.\n * Built on native radios inside a radiogroup, so arrow keys and form submission come for free.\n */\nexport function FeedbackScale({\n  value,\n  defaultValue = null,\n  onChange,\n  onSubmit,\n  question = \"How was your experience?\",\n  labels = DEFAULT_LABELS,\n  showLabels = true,\n  showPreview = true,\n  showComment = true,\n  commentLabel = \"Anything we could do better?\",\n  submitLabel = \"Send feedback\",\n  thanksMessage = \"Thanks — every response is read by the team.\",\n  size = \"md\",\n  spring = { stiffness: 260, damping: 22, mass: 0.8 },\n  accent = \"#FF7448\",\n  name,\n  className = \"\",\n  style,\n  ...props\n}: FeedbackScaleProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const questionId = `${uid}-q`;\n  const commentId = `${uid}-comment`;\n  const groupName = name ?? `feedback-${uid}`;\n  // Set when \"Edit response\" is pressed: the form focuses the chosen face when it mounts again.\n  const refocusRef = React.useRef(false);\n\n  const [inner, setInner] = React.useState<number | null>(defaultValue);\n  const score = value !== undefined ? value : inner;\n  const [hover, setHover] = React.useState<number | null>(null);\n  const [comment, setComment] = React.useState(\"\");\n  const [submitted, setSubmitted] = React.useState(false);\n  const dims = SIZES[size];\n\n  // The hero face follows hover first, then the chosen score, else rests on neutral.\n  const shown = hover ?? score;\n  const mood = useMotionValue(moodOf(shown ?? 3));\n  const target = moodOf(shown ?? 3);\n  const { stiffness, damping, mass } = spring;\n\n  React.useEffect(() => {\n    if (reduceMotion) {\n      mood.jump(target);\n      return;\n    }\n    const controls = animate(mood, target, { type: \"spring\", stiffness, damping, mass });\n    return () => controls.stop();\n  }, [mood, target, reduceMotion, stiffness, damping, mass]);\n\n  const choose = (next: number) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  const submit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (score == null) return;\n    onSubmit?.({ score, label: labels[score - 1], comment: comment.trim() });\n    setSubmitted(true);\n  };\n\n  const edit = () => {\n    refocusRef.current = true;\n    setSubmitted(false);\n  };\n\n  // The two states swap with an exit animation, so focus is moved when the new one mounts.\n  const focusThanks = (el: HTMLHeadingElement | null) => el?.focus({ preventScroll: true });\n  const focusChosen = (el: HTMLFormElement | null) => {\n    if (!el || !refocusRef.current) return;\n    refocusRef.current = false;\n    el.querySelector<HTMLInputElement>(\"input[type=radio]:checked\")?.focus({ preventScroll: true });\n  };\n\n  const fade = reduceMotion\n    ? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }\n    : {\n        initial: { opacity: 0, y: 8 },\n        animate: { opacity: 1, y: 0 },\n        exit: { opacity: 0, y: -8 },\n        transition: { type: \"spring\" as const, stiffness: 320, damping: 30 },\n      };\n\n  return (\n    <div\n      className={`w-full max-w-md rounded-2xl border border-zinc-900/10 bg-white p-5 text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.18)] sm:p-6 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-none ${className}`}\n      style={{ [\"--fs-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <AnimatePresence mode=\"wait\" initial={false}>\n        {submitted && score != null ? (\n          <motion.div key=\"thanks\" {...fade} role=\"status\" className=\"flex flex-col items-center gap-3 py-4 text-center\">\n            <div className=\"relative\">\n              <motion.div\n                initial={reduceMotion ? false : { scale: 0.6, rotate: -8 }}\n                animate={{ scale: 1, rotate: 0 }}\n                transition={{ type: \"spring\", stiffness: 380, damping: 14 }}\n                className=\"[--fs-ink:#18181b] [--fs-line:transparent]\"\n                style={{ [\"--fs-face\" as string]: STEP_COLORS[score - 1] }}\n              >\n                <Face mood={moodOf(score)} className=\"size-16\" />\n              </motion.div>\n              <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className=\"absolute -right-1.5 -bottom-1 size-7 rounded-full bg-zinc-950 p-1 text-white ring-2 ring-white dark:bg-white dark:text-zinc-950 dark:ring-zinc-900\">\n                <motion.path\n                  d=\"M6 12.5l4 4 8-9\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2.4\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  initial={reduceMotion ? false : { pathLength: 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={{ duration: 0.45, delay: 0.15, ease: [0.65, 0, 0.35, 1] }}\n                />\n              </svg>\n            </div>\n            <h3 ref={focusThanks} tabIndex={-1} className=\"mt-1 text-lg font-semibold tracking-[-0.02em] outline-hidden\">\n              Feedback sent\n            </h3>\n            <p className=\"max-w-[30ch] text-sm text-zinc-600 dark:text-zinc-400\">{thanksMessage}</p>\n            <p className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              {score} / 5 · {labels[score - 1]}\n            </p>\n            <button\n              type=\"button\"\n              onClick={edit}\n              className=\"mt-1 rounded-full px-3 py-1.5 text-xs font-medium text-zinc-600 underline decoration-zinc-900/20 underline-offset-4 transition-colors outline-hidden hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white\"\n            >\n              Edit response\n            </button>\n          </motion.div>\n        ) : (\n          <motion.form key=\"form\" ref={focusChosen} {...fade} onSubmit={submit} className=\"flex flex-col gap-5\">\n            <div className=\"flex items-center gap-4\">\n              {showPreview && <MorphFace mood={mood} idle={shown == null} className={`${dims.hero} shrink-0`} />}\n              <div className=\"min-w-0\">\n                <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">Feedback</span>\n                <p id={questionId} className=\"text-[17px] leading-snug font-semibold tracking-[-0.02em]\">\n                  {question}\n                </p>\n                <p aria-hidden=\"true\" className=\"h-5 font-mono text-[11px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\">\n                  {shown != null ? labels[shown - 1] : \"Choose a face\"}\n                </p>\n              </div>\n            </div>\n\n            <div\n              role=\"radiogroup\"\n              aria-labelledby={questionId}\n              onPointerLeave={() => setHover(null)}\n              className=\"grid grid-cols-5 gap-1.5\"\n            >\n              {labels.map((text, i) => {\n                const step = i + 1;\n                const checked = score === step;\n                const lit = checked || hover === step;\n                return (\n                  <label\n                    key={step}\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover(step)}\n                    className=\"group/step relative flex cursor-pointer flex-col items-center gap-1.5 rounded-xl px-0.5 py-2 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-1 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-[color:var(--fs-accent)] hover:bg-zinc-900/[0.03] dark:hover:bg-white/[0.04]\"\n                    style={{ [\"--fs-step\" as string]: STEP_COLORS[i] }}\n                  >\n                    <input\n                      type=\"radio\"\n                      name={groupName}\n                      value={step}\n                      checked={checked}\n                      onChange={() => choose(step)}\n                      aria-label={`${text}, ${step} of 5`}\n                      className=\"peer absolute inset-0 m-0 cursor-pointer appearance-none rounded-xl opacity-0\"\n                    />\n                    <span\n                      className={`block transition-transform duration-200 ease-out motion-reduce:transition-none ${\n                        checked ? \"scale-110\" : lit ? \"scale-105\" : \"scale-100\"\n                      } ${\n                        lit\n                          ? \"[--fs-face:var(--fs-step)] [--fs-ink:#18181b] [--fs-line:transparent]\"\n                          : \"[--fs-cheek:transparent] [--fs-face:transparent] [--fs-ink:#71717a] [--fs-line:rgb(24_24_27/0.18)] dark:[--fs-ink:#a1a1aa] dark:[--fs-line:rgb(255_255_255/0.2)]\"\n                      }`}\n                    >\n                      <Face mood={moodOf(step)} className={dims.face} />\n                    </span>\n                    {showLabels && (\n                      <span\n                        aria-hidden=\"true\"\n                        className={`text-center leading-tight ${dims.label} ${\n                          checked ? \"font-medium text-zinc-950 dark:text-white\" : \"text-zinc-500 dark:text-zinc-400\"\n                        }`}\n                      >\n                        {text}\n                      </span>\n                    )}\n                    {checked && (\n                      <motion.span\n                        layoutId={reduceMotion ? undefined : `${uid}-dot`}\n                        aria-hidden=\"true\"\n                        className=\"absolute -bottom-1 h-0.5 w-6 rounded-full bg-[var(--fs-step)]\"\n                        transition={{ type: \"spring\", stiffness: 500, damping: 36 }}\n                      />\n                    )}\n                  </label>\n                );\n              })}\n            </div>\n\n            <AnimatePresence initial={false}>\n              {score != null && (\n                <motion.div\n                  key=\"follow-up\"\n                  initial={reduceMotion ? false : { height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { height: 0, opacity: 0 }}\n                  transition={{ type: \"spring\", stiffness: 300, damping: 32 }}\n                  className=\"overflow-hidden\"\n                >\n                  <div className=\"flex flex-col gap-3 p-0.5\">\n                    {showComment && (\n                      <div className=\"flex flex-col gap-1.5\">\n                        <label htmlFor={commentId} className=\"text-[13px] font-medium text-zinc-700 dark:text-zinc-300\">\n                          {commentLabel} <span className=\"font-normal text-zinc-400 dark:text-zinc-500\">(optional)</span>\n                        </label>\n                        <textarea\n                          id={commentId}\n                          name={`${groupName}-comment`}\n                          rows={3}\n                          value={comment}\n                          onChange={(e) => setComment(e.target.value)}\n                          className=\"w-full resize-none rounded-lg border border-zinc-900/15 bg-transparent px-3 py-2 text-sm text-zinc-950 placeholder:text-zinc-400 outline-hidden transition-colors focus-visible:border-transparent focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] dark:border-white/15 dark:text-zinc-50\"\n                          placeholder=\"Optional details…\"\n                        />\n                      </div>\n                    )}\n                    <div className=\"flex items-center justify-between gap-3\">\n                      <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n                        {score} / 5\n                      </span>\n                      <button\n                        type=\"submit\"\n                        className=\"h-9 rounded-full bg-[var(--fs-accent)] px-4 text-sm font-medium text-zinc-950 transition-[filter,transform] outline-hidden hover:brightness-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] active:scale-[0.98]\"\n                      >\n                        {submitLabel}\n                      </button>\n                    </div>\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </motion.form>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/feedback-scale"
}
