{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback-widget",
  "title": "Feedback Widget",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A feedback pill that opens a popover with five face ratings as a radio group, optional topic chips and a comment box, then a thank-you confirmation.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/popover.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/feedback-widget.tsx",
      "content": "// Ballmac UI: Feedback Widget. https://ui.ballmac.com/components/feedback-widget\n\"use client\";\n\nimport * as React from \"react\";\nimport { Angry, CircleCheck, Frown, Laugh, LoaderCircle, Meh, MessageSquareText, Smile } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ballmac/popover\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages, defineMessage } from \"@/lib/ballmac/i18n\";\n\ntype FeedbackSubmission = {\n  /** 1 (very unhappy) to 5 (delighted). */\n  rating: number;\n  /** Free-text comment, may be empty. */\n  message: string;\n  /** Chosen topic, if topics were given. */\n  topic?: string;\n};\n\ntype FeedbackWidgetProps = Omit<React.ComponentProps<\"button\">, \"onSubmit\"> & {\n  /** Called with the feedback. Return a promise to show a sending state. */\n  onSubmit?: (feedback: FeedbackSubmission) => void | Promise<void>;\n  /** Topics people can tag feedback with. */\n  topics?: string[];\n  /** Text on the trigger. */\n  triggerLabel?: string;\n  /** Question shown in the panel. */\n  question?: string;\n  /** Require a written message before sending. */\n  requireMessage?: boolean;\n  /** Milliseconds before the panel closes itself after a successful send. 0 keeps it open. */\n  closeAfter?: number;\n  /** Side the panel opens on. */\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Initial open state. */\n  defaultOpen?: boolean;\n};\n\nconst FACES = [\n  { value: 1, label: defineMessage(\"feedback-widget.FACES.0\", \"Very unhappy\"), icon: Angry, tone: \"text-destructive\" },\n  { value: 2, label: defineMessage(\"feedback-widget.FACES.1\", \"Unhappy\"), icon: Frown, tone: \"text-chart-3\" },\n  { value: 3, label: defineMessage(\"feedback-widget.FACES.2\", \"Neutral\"), icon: Meh, tone: \"text-muted-foreground\" },\n  { value: 4, label: defineMessage(\"feedback-widget.FACES.3\", \"Happy\"), icon: Smile, tone: \"text-chart-2\" },\n  { value: 5, label: defineMessage(\"feedback-widget.FACES.4\", \"Delighted\"), icon: Laugh, tone: \"text-chart-1\" },\n] as const;\n\n/**\n * A compact feedback form in a popover: five face ratings (a real radio group), optional topic chips and a comment box,\n * then a confirmation. Opens from a pill button you can dock in a corner of your app.\n */\nfunction FeedbackWidget({\n  onSubmit,\n  topics,\n  triggerLabel,\n  question = \"How is your experience?\",\n  requireMessage = false,\n  closeAfter = 2200,\n  side = \"top\",\n  align = \"end\",\n  defaultOpen = false,\n  className,\n  ...props\n}: FeedbackWidgetProps) {\n  const msg = useMessages()\n  triggerLabel ??= msg(\"feedback-widget.triggerLabel\", \"Feedback\")\n  const reduce = useReducedMotion();\n  const name = React.useId();\n  const messageId = React.useId();\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [rating, setRating] = React.useState(0);\n  const [topic, setTopic] = React.useState<string | undefined>();\n  const [message, setMessage] = React.useState(\"\");\n  const [status, setStatus] = React.useState<\"idle\" | \"sending\" | \"sent\">(\"idle\");\n\n  React.useEffect(() => {\n    if (status !== \"sent\" || !closeAfter) return;\n    const id = window.setTimeout(() => setOpen(false), closeAfter);\n    return () => window.clearTimeout(id);\n  }, [status, closeAfter]);\n\n  async function submit(event: React.FormEvent) {\n    event.preventDefault();\n    if (!rating || status === \"sending\") return;\n    setStatus(\"sending\");\n    try {\n      await onSubmit?.({ rating, message: message.trim(), topic });\n      setStatus(\"sent\");\n    } catch {\n      setStatus(\"idle\");\n    }\n  }\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) {\n          window.setTimeout(() => {\n            setStatus(\"idle\");\n            setRating(0);\n            setMessage(\"\");\n            setTopic(undefined);\n          }, 200);\n        }\n      }}\n    >\n      <PopoverTrigger\n        data-slot=\"feedback-widget\"\n        className={cn(\n          \"inline-flex h-10 items-center gap-2 rounded-full border bg-background px-4 text-sm font-medium shadow-[0_4px_16px_-6px_rgb(0_0_0/0.25)] outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:bg-accent\",\n          className,\n        )}\n        {...props}\n      >\n        <MessageSquareText aria-hidden=\"true\" className=\"size-4\" />\n        {triggerLabel}\n      </PopoverTrigger>\n      <PopoverContent label=\"Send feedback\" side={side} align={align} className=\"w-[min(21rem,calc(100vw-1.5rem))] gap-0 p-0\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {status === \"sent\" ? (\n            <motion.div\n              key=\"sent\"\n              role=\"status\"\n              initial={reduce ? false : { opacity: 0, scale: 0.96 }}\n              animate={{ opacity: 1, scale: 1 }}\n              transition={spring.gentle}\n              className=\"flex flex-col items-center gap-3 px-6 py-10 text-center\"\n            >\n              <motion.span\n                initial={reduce ? false : { scale: 0 }}\n                animate={{ scale: 1 }}\n                transition={spring.bouncy}\n                className=\"flex size-12 items-center justify-center rounded-full bg-chart-2/15 text-chart-2\"\n              >\n                <CircleCheck aria-hidden=\"true\" className=\"size-6\" />\n              </motion.span>\n              <div>\n                <p className=\"text-sm font-semibold\">{msg(\"feedback-widget.thankYou\", \"Thank you\")}</p>\n                <p className=\"mt-1 text-sm text-muted-foreground\">{msg(\"feedback-widget.yourFeedbackHelpsUsImprove\", \"Your feedback helps us improve.\")}</p>\n              </div>\n            </motion.div>\n          ) : (\n            <motion.form key=\"form\" onSubmit={submit} exit={{ opacity: 0 }} className=\"grid gap-4 p-4\">\n              <fieldset className=\"grid gap-3\">\n                <legend className=\"text-sm font-semibold tracking-tight\">{question}</legend>\n                <div className=\"flex justify-between gap-1\">\n                  {FACES.map(({ value, label, icon: Icon, tone }) => (\n                    <label key={value} className=\"relative cursor-pointer\">\n                      <input\n                        type=\"radio\"\n                        name={name}\n                        value={value}\n                        checked={rating === value}\n                        onChange={() => setRating(value)}\n                        className=\"peer sr-only\"\n                      />\n                      <span\n                        className={cn(\n                          \"flex size-11 items-center justify-center rounded-xl border border-transparent transition-[background-color,border-color,transform,box-shadow] duration-150 hover:bg-accent peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                          rating === value ? `border-border bg-muted shadow-sm ${reduce ? \"\" : \"scale-110\"}` : \"text-muted-foreground\",\n                          rating === value && tone,\n                        )}\n                      >\n                        <Icon aria-hidden=\"true\" className=\"size-6\" strokeWidth={1.75} />\n                        <span className=\"sr-only\">{msg.of(label)}</span>\n                      </span>\n                    </label>\n                  ))}\n                </div>\n              </fieldset>\n\n              {topics && topics.length > 0 && (\n                <div role=\"group\" aria-label={msg(\"feedback-widget.topic\", \"Topic\")} className=\"flex flex-wrap gap-1.5\">\n                  {topics.map((t) => (\n                    <button\n                      key={t}\n                      type=\"button\"\n                      aria-pressed={topic === t}\n                      onClick={() => setTopic(topic === t ? undefined : t)}\n                      className=\"h-7 rounded-full border px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground\"\n                    >\n                      {t}\n                    </button>\n                  ))}\n                </div>\n              )}\n\n              <div className=\"grid gap-1.5\">\n                <label htmlFor={messageId} className=\"text-xs font-medium text-muted-foreground\">\n                  {msg(\"feedback-widget.tellUsMore\", \"Tell us more\")} {requireMessage ? \"\" : \"(optional)\"}\n                </label>\n                <textarea\n                  id={messageId}\n                  rows={3}\n                  required={requireMessage}\n                  value={message}\n                  onChange={(e) => setMessage(e.target.value)}\n                  placeholder={msg(\"feedback-widget.whatWorkedWellOrWhat\", \"What worked well, or what could be better?\")}\n                  className=\"min-h-20 w-full resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30\"\n                />\n              </div>\n\n              <button\n                type=\"submit\"\n                disabled={!rating || status === \"sending\" || (requireMessage && !message.trim())}\n                className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary text-sm font-medium text-primary-foreground shadow-xs outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n              >\n                {status === \"sending\" && <LoaderCircle aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />}\n                {status === \"sending\" ? \"Sending…\" : \"Send feedback\"}\n              </button>\n            </motion.form>\n          )}\n        </AnimatePresence>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport { FeedbackWidget, type FeedbackWidgetProps, type FeedbackSubmission };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/feedback-widget.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "feedback",
      "rating",
      "survey",
      "popover"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "<FeedbackWidget onSubmit topics question />. onSubmit receives {rating, message, topic}; return a promise to show sending. Dock it in a corner with your own positioning.",
      "whenToUse": [
        "Collecting product feedback in-app",
        "Page 'was this helpful' prompts"
      ],
      "whenNotToUse": [
        "Long surveys",
        "Support requests; link to a help form"
      ],
      "composesWith": [
        "popover",
        "rating",
        "textarea"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move between faces (native radios); each has a text label"
        },
        {
          "keys": "Tab",
          "action": "Topic chips, comment and Send are in order"
        },
        {
          "keys": "Screen readers",
          "action": "Success is announced as a status"
        }
      ],
      "customization": [
        "topics",
        "question",
        "requireMessage",
        "closeAfter",
        "side and align"
      ]
    },
    "examples": [
      "feedback-widget-demo",
      "feedback-widget-states"
    ],
    "url": "https://ui.ballmac.com/components/feedback-widget"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}