{
  "name": "approve-switch-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "cssVars": {
    "light": {
      "as-bg": "var(--background, oklch(1 0 0))",
      "as-pill": "#f2f2f2",
      "as-pill-ink": "#0a0a0a",
      "as-approve": "#4cc85c",
      "as-reject": "#f0392b",
      "as-on": "#ffffff",
      "as-confetti-ink": "#0b0b0b"
    },
    "dark": {
      "as-bg": "var(--background, oklch(0.145 0 0))",
      "as-pill": "oklch(0.38 0 0)",
      "as-pill-ink": "oklch(0.985 0 0)",
      "as-approve": "#4cc85c",
      "as-reject": "#f0392b",
      "as-on": "#ffffff",
      "as-confetti-ink": "oklch(0.985 0 0)"
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/approve-switch/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\";\n\n/* ═════════════════════════════════════════════════════════\n * ANIMATION STORYBOARD — approve / reject switch\n *\n *  screen 1   two equal pills sit side by side: Reject, Approve.\n *  on click   THE PRESSED PILL EXPANDS IN PLACE — its colour floods, it\n *             stretches wide — while the other pill collapses into an undo\n *             circle.\n *  the word   \"Reject\" does not cross-fade into \"Rejected\". The base word is\n *             one text node that never unmounts, so it glides inside the\n *             widening pill; only the suffix — \"ed\", \"d\" — mounts, dropping\n *             in from above at the end of the word.\n *  undo       plays the whole thing in reverse.\n *\n * ONE NODE PER SLOT\n *   State changes a slot's size, colour and children — never its identity.\n *   So `layout` replaces `layoutId`: same element, Motion measures before and\n *   after and interpolates. The word needs no per-letter identity because it\n *   is never destroyed, and the badge recolours in place instead of\n *   cross-fading, because it is the same <svg> the whole way through.\n *\n * WHY popLayout\n *   During a swap the outgoing element leaves flow immediately, so the\n *   surviving sibling reflows to its final position on frame one and glides\n *   there, instead of waiting for the exit and then jumping.\n * ═════════════════════════════════════════════════════════ */\n\n/* Deciding is a deliberate act, so it carries a little weight and a little\n * bounce; undo is a correction the user wants over with, so it snaps back\n * flat. Same family of spring either way — the block still reads as one\n * object, it just knows which direction it is travelling. */\nconst DECIDE: Transition = { type: \"spring\", duration: 0.42, bounce: 0.18 };\nconst UNDO: Transition = { type: \"spring\", duration: 0.3, bounce: 0 };\n\n/* Content swaps are quicker and bounce-free: they are changes of substance,\n * not physical moves, and overshoot on text looks like a glitch. */\nconst SWAP: Transition = { type: \"spring\", duration: 0.32, bounce: 0 };\n\n/* Leaving is shorter than arriving: while the pill shrinks, a label still\n * fading is wider than the box around it and reads as text escaping. */\nconst LEAVE: Transition = { type: \"spring\", duration: 0.16, bounce: 0 };\n\n/* The glyph swap is the fastest thing in the block — a crossfade in a\n * fixed-size box, not a move. Short and bounce-free so the icon lands with\n * the pill instead of trailing it. */\nconst MARK: Transition = { type: \"spring\", duration: 0.2, bounce: 0 };\n\n/* Colour is not a physical move, so it gets no bounce — but it is still a\n * spring, like everything else in the block: nothing here is a tween. A\n * bounce-free spring settles on a fill without the flicker an overshooting\n * one would give it. */\nconst TINT: Transition = { type: \"spring\", duration: 0.24, bounce: 0 };\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\n/* ── Colour ────────────────────────────────────────────────\n * Every colour in the block is a `--as-*` token read through `var()`, so the\n * whole thing follows the theme with no JS in the loop.\n *\n * Motion drives the *shape* — that is where a spring earns its keep. It does\n * not drive the fills, for two reasons. It has to parse a colour to tween it,\n * and it cannot parse the `oklch()` the dark tokens are written in; and even\n * where it can, it resolves a `var()` target once, at the moment the\n * animation starts, then writes the literal into inline style — so a pill\n * that has already settled keeps the old theme's fill when the theme flips.\n *\n * Instead the resting pill is a plain painted background that never changes,\n * the decision tone is an overlay whose OPACITY Motion springs (opacity has\n * no parsing problem and no theme), and the two ink swaps are CSS transitions\n * on `color`, `fill` and `stroke` at the same 240ms.\n * ───────────────────────────────────────────────────────── */\n\n/* Matches TINT's perceptual length, so the ink flips in step with the tone\n * flooding underneath it. */\nconst TINT_MS = \"240ms\";\n\n/* The suffix is the whole point of the swap: it arrives from above and\n * leaves downward, so the past tense reads as dropping into place. */\nconst suffixVariants = {\n  initial: { opacity: 0, y: \"-0.55em\", filter: \"blur(4px)\" },\n  active: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n  exit: { opacity: 0, y: \"0.55em\", filter: \"blur(4px)\", transition: LEAVE },\n};\n\n/* ── Reward layer ──────────────────────────────────────────\n * Two one-shot flourishes, mounted only while a slot is expanded and torn\n * down on undo, so they replay on every fresh decision.\n *\n * Approve gets confetti: a fan of particles thrown upward that arc over and\n * fall. Reject deliberately gets NO particles — a burst is a celebration, and\n * celebrating a rejection is the wrong feeling. It gets impact instead: a\n * short shake and a single ring pulsing out of the pill.\n *\n * Both are rare, deliberate, once-per-decision moments, which is the only\n * place this much motion is earned.\n * ───────────────────────────────────────────────────────── */\n\n/* Deterministic pseudo-random: Math.random() would render different values on\n * the server and the client and blow up hydration. Seeded by index, this is\n * the same field every time — and a fixed field is easier to art-direct. */\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  /* The sixth is the ink, and it is the one that has to flip: the particles\n   * leave the pill and land on the page, so a white chip is invisible on a\n   * light stage and a black one on a dark stage. A var() is fine here — this\n   * is a static `background`, not something Motion is tweening. */\n  \"var(--as-confetti-ink)\",\n];\n\nconst CONFETTI = Array.from({ length: 18 }, (_, i) => {\n  /* Fan the throw across the upper half only — particles leaving downward\n   * out of a pill look like debris, not 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 is clipped. */\n    <span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute top-1/2 left-1/2 z-[1] 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. Splitting x and y\n           * lets gravity live only on y — the horizontal throw keeps its\n           * ease-out while the vertical leg eases IN on the way down, which\n           * is what makes it read as weight rather than a fade. */\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\n/* The rejection's answer to confetti: one ring leaving the pill and gone.\n * `boxShadow` rather than a border so it costs no layout and can be scaled.\n * The tone is passed as a token name, not a literal — nothing animates the\n * colour here, so it can stay live against the theme. */\nfunction ImpactRing({ toneVar }: { toneVar: string }) {\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-0\"\n      style={{ borderRadius: RADIUS, boxShadow: `0 0 0 2px var(${toneVar})` }}\n      initial={{ opacity: 0.5, scale: 1 }}\n      animate={{ opacity: 0, scale: 1.14 }}\n      transition={{ type: \"spring\", duration: 0.45, bounce: 0 }}\n    />\n  );\n}\n\ntype SlotId = \"reject\" | \"approve\";\ntype Clicked = SlotId | null;\n\nconst SLOTS: {\n  id: SlotId;\n  label: string;\n  suffix: string;\n  toneVar: \"--as-reject\" | \"--as-approve\";\n  glyph: \"cross\" | \"check\";\n}[] = [\n  {\n    id: \"reject\",\n    label: \"Reject\",\n    suffix: \"ed\",\n    toneVar: \"--as-reject\",\n    glyph: \"cross\",\n  },\n  {\n    id: \"approve\",\n    label: \"Approve\",\n    suffix: \"d\",\n    toneVar: \"--as-approve\",\n    glyph: \"check\",\n  },\n];\n\n/* Shared sizing. The badge box and the collapsed circle have to be the same\n * measurement as the pill's height, so the three clamps are written once. */\nconst PILL_H = \"clamp(56px,6.4vw,72px)\";\nconst MARK_SIZE = \"clamp(26px,2.9vw,38px)\";\n\n/* One <svg> for the life of the slot. Idle it is a tinted disc with a white\n * glyph; expanded the two swap — animated, not re-rendered, so the badge\n * never blinks. Only the slot that LOSES has to change glyph, and that one\n * swap is the single crossfade left in the component. */\nfunction SlotMark({\n  glyph,\n  expanded,\n  collapsed,\n}: {\n  glyph: \"cross\" | \"check\";\n  expanded: boolean;\n  collapsed: boolean;\n}) {\n  /* --as-tone is set on the button, so the disc reads its slot's own colour\n   * without either being passed down as a literal. Expanded, the disc and\n   * the glyph trade places. */\n  const disc = expanded ? \"var(--as-on)\" : \"var(--as-tone)\";\n  const ink = expanded ? \"var(--as-tone)\" : \"var(--as-on)\";\n  const path =\n    glyph === \"check\"\n      ? \"M6.8 12.4 10.4 16 17.2 8.8\"\n      : \"M8.2 8.2 15.8 15.8M15.8 8.2 8.2 15.8\";\n\n  /* No `mode` — the two glyphs are stacked in the same fixed box and fade\n     through each other. `mode=\"wait\"` would hold the incoming one back until\n     the outgoing had finished leaving, which reads as a late-arriving icon. */\n  return (\n    <AnimatePresence initial={false}>\n      {collapsed ? (\n        <motion.svg\n          key=\"undo\"\n          className=\"absolute inset-0 block h-full w-full\"\n          viewBox=\"0 0 24 24\"\n          aria-hidden=\"true\"\n          initial={{ opacity: 0, filter: \"blur(4px)\", scale: 0.9 }}\n          animate={{ opacity: 1, filter: \"blur(0px)\", scale: 1 }}\n          exit={{ opacity: 0, filter: \"blur(4px)\", scale: 0.9 }}\n          transition={MARK}\n        >\n          <path\n            d=\"M9 6 4.5 10.5 9 15\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n          <path\n            d=\"M4.5 10.5h9a5.5 5.5 0 0 1 0 11H10\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </motion.svg>\n      ) : (\n        <motion.svg\n          key=\"glyph\"\n          className=\"absolute inset-0 block h-full w-full\"\n          viewBox=\"0 0 24 24\"\n          aria-hidden=\"true\"\n          initial={{ opacity: 0, filter: \"blur(4px)\", scale: 0.9 }}\n          animate={{ opacity: 1, filter: \"blur(0px)\", scale: 1 }}\n          exit={{ opacity: 0, filter: \"blur(4px)\", scale: 0.9 }}\n          transition={MARK}\n        >\n          <circle\n            cx=\"12\"\n            cy=\"12\"\n            r=\"12\"\n            className=\"transition-[fill] ease-out\"\n            style={{ fill: disc, transitionDuration: TINT_MS }}\n          />\n          <path\n            d={path}\n            fill=\"none\"\n            className=\"transition-[stroke] ease-out\"\n            style={{ stroke: ink, transitionDuration: TINT_MS }}\n            strokeWidth=\"2.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </motion.svg>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport default function ApproveSwitch({ className }: { className?: string }) {\n  const [clicked, setClicked] = useState<Clicked>(null);\n  /* Confetti and a shake are decoration, so under reduced motion they are not\n   * softened — they are simply not mounted. The decision still reads without\n   * them; the pill and the word carry it. */\n  const reduced = useReducedMotion();\n  /* Returning to idle IS the undo, whichever slot the press came from. */\n  const travel = clicked === null ? UNDO : DECIDE;\n\n  return (\n    <MotionConfig transition={travel} reducedMotion=\"user\">\n      <div\n        className={`flex min-h-full w-full items-center justify-center p-[clamp(16px,3vw,36px)] ${className ?? \"\"}`}\n      >\n        {/* The row is the shared frame both screens live in: the two slots\n            keep their order across states, so the morph only ever has to\n            change size and colour. */}\n        <div className=\"flex items-center gap-[clamp(10px,1.4vw,18px)]\">\n          {SLOTS.map((slot) => {\n            const expanded = clicked === slot.id;\n            const collapsed = clicked !== null && !expanded;\n\n            const celebrate = expanded && slot.id === \"approve\" && !reduced;\n            const rebuff = expanded && slot.id === \"reject\" && !reduced;\n\n            return (\n              /* The wrapper does not participate in the layout animation, so\n                 it is a stable frame to hang the flourishes on — and shaking\n                 it leaves the button's own transform free for `layout`, which\n                 would otherwise fight an `x` animation on the same node. */\n              <motion.span\n                key={slot.id}\n                className=\"relative inline-flex\"\n                animate={{ x: rebuff ? [0, -7, 6, -4, 3, -1.5, 0] : 0 }}\n                transition={{ duration: 0.4, ease: \"easeOut\" }}\n              >\n                <AnimatePresence>\n                  {rebuff && <ImpactRing key=\"ring\" toneVar={slot.toneVar} />}\n                </AnimatePresence>\n                <AnimatePresence>\n                  {celebrate && <Confetti key=\"confetti\" />}\n                </AnimatePresence>\n                <motion.button\n                  type=\"button\"\n                  layout\n                  style={{\n                    borderRadius: RADIUS,\n                    height: PILL_H,\n                    /* The slot's own decision colour, published to every\n                       descendant — the tone overlay and the badge disc both\n                       read it, so neither has to be handed a literal. */\n                    [\"--as-tone\" as string]: `var(${slot.toneVar})`,\n                    color: expanded ? \"var(--as-on)\" : \"var(--as-pill-ink)\",\n                    transitionDuration: TINT_MS,\n                  }}\n                  transition={{ layout: travel }}\n                  whileTap={{ scale: 0.96 }}\n                  onClick={() => setClicked(clicked === null ? slot.id : null)}\n                  aria-label={collapsed ? \"Undo decision\" : undefined}\n                  aria-pressed={expanded}\n                  className={[\n                    /* The resting pill is painted here and never changes —\n                       the decision tone floods in as an overlay on top of it,\n                       so this background is what is visible at rest, before\n                       hydration, and behind every state. */\n                    \"relative flex cursor-pointer items-center justify-center gap-3 border-0 bg-[var(--as-pill)] transition-[color] ease-out select-none\",\n                    \"text-[clamp(18px,1.9vw,26px)] leading-none font-bold tracking-[-0.01em] whitespace-nowrap\",\n                    /* Clips the label on its way out, so nothing is ever\n                       visible outside the pill while the pill shrinks. */\n                    \"overflow-hidden [-webkit-tap-highlight-color:transparent]\",\n                    \"outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-current\",\n                    /* A fixed expanded width keeps the morph from snapping to\n                       content width when the suffix lands a beat after the\n                       pill has finished growing. Collapsed is a circle, so\n                       width has to equal the shared height. */\n                    expanded\n                      ? \"w-[clamp(280px,34vw,420px)] p-0\"\n                      : collapsed\n                        ? \"w-[clamp(56px,6.4vw,72px)] gap-0 p-0\"\n                        : \"px-[clamp(22px,2.6vw,34px)]\",\n                  ].join(\" \")}\n                >\n                  {/* The decision tone. An overlay rather than a background\n                      on the button itself: opacity is the one visual channel\n                      Motion can spring without parsing a colour, which keeps\n                      the fill a live `var()` and the flood a real spring.\n                      rounded-[inherit] picks up the pill's px radius, so it\n                      is corrected by the same layout projection. */}\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute inset-0 rounded-[inherit] bg-[var(--as-tone)]\"\n                    initial={false}\n                    animate={{ opacity: expanded ? 1 : 0 }}\n                    transition={TINT}\n                  />\n\n                  {/* A fixed box the two glyphs are stacked inside, so they\n                      fade through each other simultaneously instead of taking\n                      turns, and neither reflows the pill while swapping.\n                      layout=\"position\" keeps it centred while the pill grows\n                      instead of being stretched by the parent's morph. */}\n                  <motion.span\n                    layout=\"position\"\n                    className=\"relative z-10 flex flex-none items-center justify-center\"\n                    style={{ width: MARK_SIZE, height: MARK_SIZE }}\n                  >\n                    <SlotMark\n                      glyph={slot.glyph}\n                      expanded={expanded}\n                      collapsed={collapsed}\n                    />\n                  </motion.span>\n\n                  {/* popLayout takes the label out of flow the instant it\n                      starts leaving, so the collapsing pill shrinks around it\n                      instead of waiting for the fade and then snapping. */}\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {!collapsed && (\n                      <motion.span\n                        key=\"label\"\n                        layout=\"position\"\n                        className=\"relative z-10 inline-flex items-baseline whitespace-pre\"\n                        initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                        animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                        exit={{\n                          opacity: 0,\n                          filter: \"blur(4px)\",\n                          transition: LEAVE,\n                        }}\n                        transition={SWAP}\n                      >\n                        {slot.label}\n                        <AnimatePresence mode=\"popLayout\" initial={false}>\n                          {expanded && (\n                            /* inline-block so the suffix can be moved on y;\n                               overflow stays visible so descenders are never\n                               clipped. */\n                            <motion.span\n                              key=\"suffix\"\n                              className=\"inline-block whitespace-pre\"\n                              variants={suffixVariants}\n                              initial=\"initial\"\n                              animate=\"active\"\n                              exit=\"exit\"\n                              transition={SWAP}\n                            >\n                              {slot.suffix}\n                            </motion.span>\n                          )}\n                        </AnimatePresence>\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                </motion.button>\n              </motion.span>\n            );\n          })}\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/approve-switch/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Approve Switch - Approve Switch",
    "description": "A two-pill decision control that morphs in place: the pressed slot expands and tints while the other collapses to an undo circle, the word completes itself with a dropped-in suffix, and each verdict gets its own one-shot flourish.",
    "category": "morphing-buttons",
    "tags": [
      "morph",
      "button",
      "approve",
      "reject",
      "decision",
      "undo",
      "confetti",
      "layout",
      "click",
      "spring",
      "dark-mode",
      "theming"
    ],
    "animationType": "click"
  }
}