{
  "name": "feedback-button-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion",
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "cssVars": {
    "light": {
      "fb-bg": "var(--background, oklch(1 0 0))",
      "fb-fg": "var(--foreground, oklch(0.145 0 0))",
      "fb-pill": "#f2f0ea",
      "fb-pill-hover": "#eae6dd",
      "fb-undo": "rgba(0, 0, 0, 0.055)",
      "fb-undo-hover": "rgba(0, 0, 0, 0.1)",
      "fb-ring": "oklch(0.145 0 0)",
      "fb-confetti-ink": "#0b0b0b"
    },
    "dark": {
      "fb-bg": "var(--background, oklch(0.145 0 0))",
      "fb-fg": "var(--foreground, oklch(0.985 0 0))",
      "fb-pill": "oklch(0.38 0.006 85)",
      "fb-pill-hover": "oklch(0.43 0.006 85)",
      "fb-undo": "rgba(255, 255, 255, 0.08)",
      "fb-undo-hover": "rgba(255, 255, 255, 0.16)",
      "fb-ring": "oklch(0.985 0 0)",
      "fb-confetti-ink": "oklch(0.985 0 0)"
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/feedback-button/default.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  IconArrowBackUp,\n  IconThumbDownFilled,\n  IconThumbUpFilled,\n} from \"@tabler/icons-react\";\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD — feedback switch\n *\n *  scene 1   two identical thumb pills, no labels.\n *  on vote   THE PRESSED PILL STAYS AND STRETCHES — same node, so its thumb\n *            never blinks, it just slides left as the pill grows around it.\n *            The other pill is the only thing that unmounts, popping out of\n *            flow so the survivor glides into the space rather than waiting.\n *  arriving  \"Feedback Received!\" drops in from above, blurred; the Undo\n *            capsule slides in from the right edge it is anchored to.\n *  undo      plays in reverse and the second pill returns.\n *\n * Same architecture as ApproveSwitch v2: one persistent node per option,\n * `layout` (not `layoutId`) doing the morph, and only *content* mounting and\n * unmounting inside it.\n * ───────────────────────────────────────────────────────── */\n\ntype Vote = \"up\" | \"down\";\n\n/* Voting is deliberate and carries a little weight; undo is a correction the\n * user wants over with, so it snaps back flat. */\nconst DECIDE: Transition = { type: \"spring\", duration: 0.42, bounce: 0.18 };\nconst UNDO: Transition = { type: \"spring\", duration: 0.3, bounce: 0 };\n\n/* Content arrives on a lively spring — the label and the Undo capsule\n * overshoot slightly and settle, so they land as objects rather than as\n * values fading in. */\nconst LIVELY: Transition = { type: \"spring\", duration: 0.4, bounce: 0.28 };\n\n/* …but only the PHYSICAL values get that bounce. Opacity and blur ride a\n * flat spring instead: bounce on them would overshoot past opaque and past\n * zero blur, which the browser clamps into a visible hitch. */\nconst SWAP: Transition = { type: \"spring\", duration: 0.32, bounce: 0 };\n\n/* Text takes a smaller bounce than the capsule: a word wobbling on its own\n * baseline reads as a glitch long before a moving box does. */\nconst TEXT: Transition = { type: \"spring\", duration: 0.4, bounce: 0.2 };\n\n/* Applied together, so one element can bounce on its move and stay clean on\n * its fade. Every entry here is a spring — the flat ones are springs with no\n * bounce, not tweens. */\nconst ARRIVE: Transition = {\n  x: LIVELY,\n  y: LIVELY,\n  scale: LIVELY,\n  opacity: SWAP,\n  filter: SWAP,\n};\n\n/* Leaving is shorter than arriving. The pill's shrink is the star of the\n * undo, and a label still fading at 300ms is visibly wider than the box it is\n * inside — it reads as text escaping the pill. Gone in half the time. */\nconst LEAVE: Transition = { type: \"spring\", duration: 0.16, bounce: 0 };\n\nconst ARRIVE_TEXT: Transition = {\n  y: TEXT,\n  opacity: SWAP,\n  filter: SWAP,\n};\n\n/* Radius must be an inline pixel value: a layout animation scales the box,\n * and Motion can only undo the corner distortion when it knows the px. */\nconst RADIUS = 999;\n\nconst OPTIONS = [\n  { id: \"up\" as const, Icon: IconThumbUpFilled, label: \"Good response\" },\n  { id: \"down\" as const, Icon: IconThumbDownFilled, label: \"Bad response\" },\n];\n\n/* ── Confetti ──────────────────────────────────────────────\n * Deliberately duplicated from ApproveSwitch rather than imported: that copy\n * is driven by classes in approve-switch.css, and this block is Tailwind-only.\n * Same field, same maths.\n *\n * Deterministic pseudo-random — Math.random() would produce different values\n * on the server and the client and break hydration. Seeded by index, the\n * field is identical every render, which also makes it art-directable. */\nfunction frac(n: number) {\n  const x = Math.sin(n * 127.1) * 43758.5453;\n  return x - Math.floor(x);\n}\n\nconst CONFETTI_COLORS = [\n  \"#4cc85c\",\n  \"#2fa8ff\",\n  \"#ffc531\",\n  \"#ff6f91\",\n  \"#8b5cf6\",\n  /* Theme-flipping ink: a var() is legal in the inline `background` these\n   * particles are painted with, and a fixed #0b0b0b would vanish in dark. */\n  \"var(--fb-confetti-ink)\",\n];\n\nconst CONFETTI = Array.from({ length: 18 }, (_, i) => {\n  /* Fan the throw across the upper half only — particles leaving downward out\n   * of a pill look like debris rather than celebration. */\n  const angle = (-166 + (142 * i) / 17 + frac(i) * 12) * (Math.PI / 180);\n  const dist = 68 + frac(i + 9) * 78;\n  const round = frac(i + 3) > 0.55;\n  return {\n    dx: Math.cos(angle) * dist,\n    dy: Math.sin(angle) * dist,\n    drift: (frac(i + 5) - 0.5) * 46,\n    fall: 96 + frac(i + 7) * 92,\n    spin: (frac(i + 11) - 0.5) * 620,\n    w: round ? 7 + frac(i + 2) * 3 : 5 + frac(i + 4) * 4,\n    h: round ? 7 + frac(i + 2) * 3 : 9 + frac(i + 6) * 5,\n    round,\n    color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],\n    dur: 0.78 + frac(i + 13) * 0.34,\n    delay: frac(i + 17) * 0.06,\n  };\n});\n\nfunction Confetti() {\n  return (\n    /* A zero-size origin pinned to the pill's centre, so every particle's\n       translation is measured from one point and nothing can clip. */\n    <span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute top-1/2 left-1/2 z-10 h-0 w-0\"\n    >\n      {CONFETTI.map((p, i) => (\n        <motion.span\n          key={i}\n          className=\"absolute top-0 left-0 block\"\n          style={{\n            width: p.w,\n            height: p.h,\n            background: p.color,\n            borderRadius: p.round ? \"50%\" : 2,\n          }}\n          initial={{ x: 0, y: 0, scale: 0.4, rotate: 0, opacity: 0 }}\n          /* Three keyframes per axis — launch, apex, fall — with x and y on\n             separate curves so gravity lives only on y: the throw keeps its\n             ease-out while the drop eases IN, which is what reads as weight.\n             Keyframed arcs are the one thing a spring cannot express, so\n             these stay tweens. */\n          animate={{\n            x: [0, p.dx, p.dx + p.drift],\n            y: [0, p.dy, p.dy + p.fall],\n            scale: [0.4, 1, 0.92],\n            rotate: [0, p.spin],\n            opacity: [0, 1, 1, 0],\n          }}\n          transition={{\n            delay: p.delay,\n            x: {\n              duration: p.dur,\n              times: [0, 0.42, 1],\n              ease: [0.16, 1, 0.3, 1],\n            },\n            y: {\n              duration: p.dur,\n              times: [0, 0.42, 1],\n              ease: [\"easeOut\", \"easeIn\"],\n            },\n            scale: { duration: p.dur, times: [0, 0.24, 1], ease: \"easeOut\" },\n            rotate: { duration: p.dur, ease: \"linear\" },\n            opacity: {\n              duration: p.dur,\n              times: [0, 0.06, 0.62, 1],\n              ease: \"linear\",\n            },\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nexport default function FeedbackSwitch({ className }: { className?: string }) {\n  const [vote, setVote] = useState<Vote | null>(null);\n  /* Confetti is decoration, so under reduced motion it is not softened — it\n   * simply never mounts. The pill and the sentence still carry the state. */\n  const reduced = useReducedMotion();\n  /* Returning to null IS the undo, whichever thumb it came from. */\n  const travel = vote === null ? UNDO : DECIDE;\n\n  return (\n    <MotionConfig transition={travel} reducedMotion=\"user\">\n      <div\n        className={`flex h-full w-full items-center justify-center bg-[var(--fb-bg)] px-4 py-10 ${className ?? \"\"}`}\n      >\n        <div className=\"flex w-full items-center justify-center gap-3 text-[var(--fb-fg)]\">\n          {/* popLayout: the losing pill leaves flow on frame one, so the\n              survivor starts gliding immediately instead of after the fade. */}\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {OPTIONS.filter(\n              (option) => vote === null || vote === option.id,\n            ).map((option) => {\n              const expanded = vote === option.id;\n\n              return (\n                /* The wrapper is the AnimatePresence child now, and it never\n                   runs a layout animation — which is exactly why the burst\n                   hangs off it. An absolutely-positioned child of a `layout`\n                   element gets scaled and smeared by that element's morph. */\n                <motion.span\n                  key={option.id}\n                  className=\"relative inline-flex shrink-0\"\n                  exit={{ opacity: 0, scale: 0.9, filter: \"blur(4px)\" }}\n                  transition={ARRIVE}\n                >\n                  <AnimatePresence>\n                    {/* Positive only — a burst is applause, and applauding a\n                        thumbs-down is the wrong feeling. */}\n                    {expanded && option.id === \"up\" && !reduced && (\n                      <Confetti key=\"confetti\" />\n                    )}\n                  </AnimatePresence>\n                  <motion.div\n                    layout\n                    style={{ borderRadius: RADIUS }}\n                    /* Not a <button>: expanded, this box CONTAINS the Undo\n                     button, and a button inside a button is invalid. Idle it\n                     takes the role instead, keyboard behaviour included. */\n                    role={expanded ? \"status\" : \"button\"}\n                    tabIndex={expanded ? undefined : 0}\n                    aria-label={expanded ? undefined : option.label}\n                    onClick={expanded ? undefined : () => setVote(option.id)}\n                    onKeyDown={(event) => {\n                      if (expanded) return;\n                      if (event.key === \"Enter\" || event.key === \" \") {\n                        event.preventDefault();\n                        setVote(option.id);\n                      }\n                    }}\n                    whileTap={expanded ? undefined : { scale: 0.97 }}\n                    transition={{ layout: travel, ...ARRIVE }}\n                    className={[\n                      \"flex h-[92px] shrink-0 items-center overflow-hidden bg-[var(--fb-pill)] select-none\",\n                      \"outline-none focus-visible:ring-2 focus-visible:ring-[var(--fb-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--fb-bg)]\",\n                      expanded\n                        ? \"w-full max-w-[560px] gap-4 pr-3 pl-8\"\n                        : \"w-[min(200px,40vw)] cursor-pointer justify-center hover:bg-[var(--fb-pill-hover)]\",\n                    ].join(\" \")}\n                  >\n                    {/* layout=\"position\" keeps the thumb its true size while the\n                      pill's width morphs — without it the parent's scale\n                      correction stretches it for the length of the move. */}\n                    <motion.span layout=\"position\" className=\"flex shrink-0\">\n                      <option.Icon size={34} stroke={0} />\n                    </motion.span>\n\n                    <AnimatePresence mode=\"popLayout\" initial={false}>\n                      {expanded && (\n                        <motion.span\n                          key=\"label\"\n                          layout=\"position\"\n                          className=\"text-[26px] font-bold tracking-tight whitespace-nowrap\"\n                          initial={{\n                            opacity: 0,\n                            y: \"-0.5em\",\n                            filter: \"blur(4px)\",\n                          }}\n                          animate={{\n                            opacity: 1,\n                            y: \"0em\",\n                            filter: \"blur(0px)\",\n                          }}\n                          exit={{\n                            opacity: 0,\n                            y: \"0.5em\",\n                            filter: \"blur(4px)\",\n                            transition: LEAVE,\n                          }}\n                          transition={ARRIVE_TEXT}\n                        >\n                          Feedback Received!\n                        </motion.span>\n                      )}\n                    </AnimatePresence>\n\n                    <AnimatePresence mode=\"popLayout\" initial={false}>\n                      {expanded && (\n                        /* Anchored to the right edge, so it enters from the edge\n                         it lives on rather than from nowhere. */\n                        <motion.button\n                          key=\"undo\"\n                          type=\"button\"\n                          layout=\"position\"\n                          style={{ borderRadius: RADIUS }}\n                          onClick={() => setVote(null)}\n                          whileTap={{ scale: 0.97 }}\n                          initial={{ opacity: 0, x: 16, filter: \"blur(4px)\" }}\n                          animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                          exit={{\n                            opacity: 0,\n                            x: 16,\n                            filter: \"blur(4px)\",\n                            transition: LEAVE,\n                          }}\n                          transition={ARRIVE}\n                          className=\"ml-auto flex h-[68px] shrink-0 cursor-pointer items-center gap-2 bg-[var(--fb-undo)] px-6 text-[24px] font-bold tracking-tight whitespace-nowrap outline-none hover:bg-[var(--fb-undo-hover)] focus-visible:ring-2 focus-visible:ring-[var(--fb-ring)]\"\n                        >\n                          <IconArrowBackUp size={28} stroke={2.6} />\n                          Undo\n                        </motion.button>\n                      )}\n                    </AnimatePresence>\n                  </motion.div>\n                </motion.span>\n              );\n            })}\n          </AnimatePresence>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/feedback-button/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Feedback Button - Feedback Button",
    "description": "A thumbs up/down pair that morphs into a confirmation bar via a single persistent node running `layout`, with an Undo capsule that plays the morph back in reverse on a faster, flat spring.",
    "category": "morphing-buttons",
    "tags": [
      "morph",
      "button",
      "feedback",
      "thumbs",
      "vote",
      "undo",
      "confetti",
      "layout",
      "click",
      "spring",
      "dark-mode",
      "theming"
    ],
    "animationType": "click"
  }
}