{
  "name": "subscribe-button-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion",
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "cssVars": {
    "light": {
      "sb-bg": "var(--background, oklch(1 0 0))",
      "sb-fg": "var(--foreground, oklch(0.145 0 0))",
      "sb-pill": "#f2f0ea",
      "sb-pill-hover": "#eae6dd",
      "sb-button": "#0b0b0b",
      "sb-button-hover": "rgba(11, 11, 11, 0.85)",
      "sb-on-button": "#ffffff",
      "sb-placeholder": "rgba(11, 11, 11, 0.3)",
      "sb-error": "#c0392b",
      "sb-ring": "oklch(0.145 0 0)",
      "sb-ink": "#0b0b0b"
    },
    "dark": {
      "sb-bg": "var(--background, oklch(0.145 0 0))",
      "sb-fg": "var(--foreground, oklch(0.985 0 0))",
      "sb-pill": "oklch(0.38 0.006 85)",
      "sb-pill-hover": "oklch(0.43 0.006 85)",
      "sb-button": "oklch(0.985 0 0)",
      "sb-button-hover": "oklch(0.9 0 0)",
      "sb-on-button": "#0b0b0b",
      "sb-placeholder": "rgba(255, 255, 255, 0.35)",
      "sb-error": "#ff7b6b",
      "sb-ring": "oklch(0.985 0 0)",
      "sb-ink": "oklch(0.985 0 0)"
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/subscribe-button/default.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState, type FormEvent } from \"react\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\nimport { IconCheck, IconMail } from \"@tabler/icons-react\";\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD — email subscription bar\n *\n *  scene 1   one narrow pill: a mail glyph and the word \"Subscribe\".\n *  on click  THE PILL ITSELF STAYS AND STRETCHES — same node, so the mail\n *            glyph never blinks, it just holds its place at the left as the\n *            box grows around it. The field and the Join button mount into\n *            the room that opens up.\n *  on submit the pill holds its width. A small spinner pops in at the left of\n *            the label while the word GROWS — \"Join\" never remounts, the\n *            letters \"i\" \"n\" \"g\" mount onto the end of it one at a time. When\n *            the request resolves the spinner pops back out, \"ing\" is replaced\n *            by \"ed\", and a throw of ribbons fires from the button itself.\n *  then      the field and button leave and the pill pulls in around\n *            \"You're subscribed\", which drops in from above. Nothing to press:\n *            after a beat the pill simply returns to where it started, so the\n *            whole run is one round trip rather than a state you must dismiss.\n *  on undo   plays in reverse, flat and quick.\n *\n * Same architecture as FeedbackSwitch: one persistent node doing the morph\n * with `layout` (never `layoutId`), every child on `layout=\"position\"` so the\n * box's scale correction cannot smear it, and only *content* mounting and\n * unmounting inside. The difference is that this pill morphs TWICE in a row —\n * idle → typing → done — which is why the box never unmounts at any point.\n * ───────────────────────────────────────────────────────── */\n\ntype Stage = \"idle\" | \"typing\" | \"pending\" | \"joined\" | \"done\";\n\n/* Opening the field is a deliberate act and carries a little weight. */\nconst DECIDE: Transition = { type: \"spring\", duration: 0.42, bounce: 0.18 };\n/* Submitting is the payoff, so it lands with slightly more life than the\n * pill opened with — the one place in the block that gets extra bounce. */\nconst SUBMIT: Transition = { type: \"spring\", duration: 0.38, bounce: 0.22 };\n/* The return leg. Going home is not a decision, so it snaps back flat — the\n * same spring the pill would have used for an explicit undo. */\nconst UNDO: Transition = { type: \"spring\", duration: 0.3, bounce: 0 };\n\n/* Content arrives on a lively spring — the label and the capsules overshoot\n * slightly and settle, so they land as objects rather than as values. */\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/* The letters. Written as stiffness/damping rather than this file's usual\n * duration/bounce because that is how it was authored and the numbers are\n * already right — it lands at roughly { duration: 0.36, bounce: 0.16 }. */\nconst LETTER: Transition = { type: \"spring\", damping: 20, stiffness: 350 };\n\n/* The suffix each phase adds to the base word. The base is never in here:\n * \"Join\" is a persistent node, and only these letters mount and unmount. */\nconst SUFFIX: Record<string, string[]> = {\n  pending: [\"i\", \"n\", \"g\"],\n  joined: [\"e\", \"d\"],\n};\n\n/* The spinner is the only thing that appears from nothing mid-request, so it\n * arrives with real pop — it has to be noticed without the box moving. */\nconst SPINNER: Transition = { type: \"spring\", duration: 0.34, bounce: 0.4 };\n\n/* The glyph swap is the fastest thing here — a crossfade inside a fixed-size\n * box, not a move. No bounce, so the icon lands rather than settles. */\nconst GLYPH: Transition = { type: \"spring\", duration: 0.26, bounce: 0 };\n\n/* Applied together, so one element can bounce on its move and stay clean on\n * its fade. Every entry 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\nconst ARRIVE_TEXT: Transition = {\n  y: TEXT,\n  opacity: SWAP,\n  filter: SWAP,\n};\n\n/* Leaving is shorter than arriving. The pill's resize is the star of every\n * transition, and a label still fading at 300ms is visibly wider than the box\n * it is inside — it reads as text escaping the pill. Gone in half the time. */\nconst LEAVE: Transition = { type: \"spring\", duration: 0.16, 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/* Deliberately loose. The only thing worth rejecting in the browser is an\n * address that cannot possibly be one; anything stricter starts refusing\n * valid mail, and the server is the real authority either way. */\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/* ── Streamers ─────────────────────────────────────────────\n * Ribbons, not confetti squares. The throw is the same field the confetti\n * used — same fan, same launch/apex/fall arc, same 0.78–1.12s clock — but\n * each particle is a long curled strip that FLIPS as it falls. That flicker\n * between broad and edge-on is what makes a ribbon read as a ribbon rather\n * than a stretched rectangle; a strip that only spins in the plane is just a\n * longer piece of confetti.\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 STREAMER_COLORS = [\n  \"#4cc85c\",\n  \"#2fa8ff\",\n  \"#ffc531\",\n  \"#ff6f91\",\n  \"#8b5cf6\",\n  /* Theme-flipping ink: a var() is legal in the inline `stroke` these ribbons\n   * are drawn with, and a fixed #0b0b0b would vanish in dark. */\n  \"var(--sb-ink)\",\n];\n\n/* One S-curve, drawn once in a 6×36 box and scaled per ribbon. Fixed curl\n * rather than per-ribbon geometry: the variety comes from length, spin and\n * flip phase, and a field of eighteen differently-shaped paths reads as\n * noise instead of as one kind of thing thrown eighteen times. */\nconst CURL = \"M3 0 C 6.5 6, -0.5 12, 3 18 S 6.5 30, 3 36\";\n\nconst STREAMERS = Array.from({ length: 18 }, (_, i) => {\n  /* Fan the throw across the upper half only — ribbons 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  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    /* In-plane spin is gentler than the confetti's: a ribbon is long, and a\n     * long thing turning 300° reads as a propeller. The flip carries the\n     * energy instead. */\n    spin: (frac(i + 11) - 0.5) * 260,\n    /* Full turns about the ribbon's own axis over the fall — two to three and\n     * a half, so no two flicker in step. Odd multiples of 180 land edge-on at\n     * the very end, which is the least visible way to leave. */\n    flips: 2 + frac(i + 3) * 1.5,\n    /* Where in the flip each one starts, so the field is not all broadside\n     * on frame one. */\n    phase: frac(i + 15) * 360,\n    /* The curl is 6 wide and 36 tall; this scales its length. Ribbons are the\n     * one particle where a wide size range helps — a couple of long ones\n     * among short ones is what gives the fall its layers. */\n    length: 18 + frac(i + 2) * 18,\n    color: STREAMER_COLORS[i % STREAMER_COLORS.length],\n    dur: 0.78 + frac(i + 13) * 0.34,\n    delay: frac(i + 17) * 0.06,\n  };\n});\n\nfunction Streamers({ origin }: { origin: { x: number; y: number } }) {\n  return (\n    /* A zero-size origin, so every ribbon's translation is measured from one\n       point. It is offset to the BUTTON's centre — measured, not guessed —\n       but it lives out here on the wrapper because the pill is\n       overflow-hidden and would clip a throw from inside it. */\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      style={{\n        transform: `translate(${origin.x}px, ${origin.y}px)`,\n        perspective: 240,\n      }}\n    >\n      {STREAMERS.map((p, i) => {\n        const w = (6 * p.length) / 36;\n        return (\n          <motion.svg\n            key={i}\n            viewBox=\"0 0 6 36\"\n            className=\"absolute top-0 left-0 block overflow-visible\"\n            style={{\n              width: w,\n              height: p.length,\n              marginLeft: -w / 2,\n              marginTop: -p.length / 2,\n              /* Flip about the ribbon's own vertical axis, from its centre —\n                 the default origin is the top-left corner, which would swing\n                 the strip around a hinge instead of turning it over. */\n              transformOrigin: \"50% 50%\",\n            }}\n            initial={{\n              x: 0,\n              y: 0,\n              scale: 0.4,\n              rotate: 0,\n              rotateX: p.phase,\n              opacity: 0,\n            }}\n            /* Same three-keyframe arc as the confetti — launch, apex, fall —\n               with x and y on separate curves so gravity lives only on y: the\n               throw keeps its ease-out while the drop eases IN. The flip is a\n               fourth track: linear rotateX for the whole flight, so the strip\n               flickers wide → edge-on → wide at an even rate as it falls.\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.94],\n              rotate: [0, p.spin],\n              rotateX: [p.phase, p.phase + 360 * p.flips],\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              rotateX: { 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            <path\n              d={CURL}\n              fill=\"none\"\n              stroke={p.color}\n              /* Thick relative to the 6-unit box so the strip has body when\n                 broadside and still leaves a visible hairline near edge-on. */\n              strokeWidth={4.2}\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </motion.svg>\n        );\n      })}\n    </span>\n  );\n}\n\n/* The word is one continuous object: \"Join\" is a plain span that outlives\n * every phase, so it never blinks, and the suffix letters arrive on its end\n * one after another. layout=\"position\" on the row lets the whole word glide\n * back to centre as it grows instead of jumping. */\nfunction ButtonLabel({ phase }: { phase: string }) {\n  const suffix = SUFFIX[phase];\n\n  return (\n    <motion.span\n      layout=\"position\"\n      transition={LETTER}\n      className=\"relative z-[1] flex\"\n    >\n      <span>Join</span>\n      {/* popLayout so the outgoing \"ing\" leaves flow on frame one and \"ed\"\n          does not wait for it to finish fading before taking its place. */}\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {suffix?.map((char, index) => (\n          <motion.span\n            /* Keyed by phase as well as index: two suffixes that shared a\n               glyph would otherwise reuse the same node and teleport. */\n            key={`${phase}-${char}-${index}`}\n            layout\n            /* inline-block, or the y offset silently does nothing — transforms\n               do not apply to inline boxes. */\n            className=\"inline-block\"\n            initial={{ opacity: 0, y: 10 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -8, transition: LEAVE }}\n            transition={{ ...LETTER, delay: 0.06 * index }}\n          >\n            {char}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport default function SubscribeButton({ className }: { className?: string }) {\n  const [stage, setStage] = useState<Stage>(\"idle\");\n  const [email, setEmail] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  /* Where the throw comes from, measured off the button at the moment it\n     resolves. A hard-coded offset would be right at one pill width only. */\n  const [burst, setBurst] = useState<{ x: number; y: number } | null>(null);\n\n  /* The streamers are decoration, so under reduced motion they are not\n   * softened — they simply never mount. The pill and the sentence still\n   * carry the state. */\n  const reduced = useReducedMotion();\n  /* The shake is imperative because it must be able to fire twice in a row on\n   * the same value: a declarative `animate` would see no change and do\n   * nothing the second time an address is rejected. */\n  const shake = useAnimationControls();\n\n  const open = stage === \"typing\" || stage === \"pending\" || stage === \"joined\";\n  const done = stage === \"done\";\n  const expanded = open || done;\n\n  /* Returning to idle IS the undo, whichever stage it came from. */\n  const travel = stage === \"idle\" ? UNDO : done ? SUBMIT : DECIDE;\n\n  function reject(message: string) {\n    setError(message);\n    /* The shake lives on the WRAPPER, never on the pill: a keyframed x and a\n     * layout animation on the same node fight over the same transform, and\n     * the pill wins by snapping. Same reason the streamers hang off here. */\n    shake.start({\n      x: [0, -7, 6, -4, 0],\n      transition: { duration: 0.28, ease: \"easeOut\" },\n    });\n  }\n\n  function submit(event: FormEvent) {\n    event.preventDefault();\n    if (stage !== \"typing\") return;\n\n    const value = email.trim();\n    if (!value) return reject(\"Enter your email address.\");\n    if (!EMAIL.test(value))\n      return reject(\"That doesn't look like an email address.\");\n\n    setError(null);\n    setStage(\"pending\");\n\n    /* Stand-in for the real request. Held long enough that the spinner reads\n     * as a state rather than a flicker — it has to finish popping in, spin a\n     * couple of full turns, and be seen. The pill deliberately does NOT resize\n     * here — pending is a change of substance inside a box that already has\n     * its final width, so nothing moves while we wait. */\n    window.setTimeout(() => {\n      const wrapper = wrapperRef.current?.getBoundingClientRect();\n      const button = buttonRef.current?.getBoundingClientRect();\n      if (wrapper && button) {\n        setBurst({\n          x:\n            button.left + button.width / 2 - (wrapper.left + wrapper.width / 2),\n          y:\n            button.top + button.height / 2 - (wrapper.top + wrapper.height / 2),\n        });\n      }\n      setStage(\"joined\");\n      /* Long enough for the spinner to leave, \"ed\" to land and the ribbons\n         to clear the button. Any shorter and the throw reads as debris from\n         a box that is already vanishing. */\n      window.setTimeout(() => {\n        setStage(\"done\");\n        /* The confirmation dismisses itself. It is a receipt, not a decision,\n           so leaving a control there would ask the user to tidy up after an\n           action that already succeeded — long enough to read the sentence\n           twice, then the pill goes home. */\n        window.setTimeout(reset, 1900);\n      }, 700);\n    }, 2000);\n  }\n\n  function reset() {\n    setStage(\"idle\");\n    setEmail(\"\");\n    setError(null);\n    setBurst(null);\n  }\n\n  return (\n    <MotionConfig transition={travel} reducedMotion=\"user\">\n      <div\n        className={`flex h-full w-full items-center justify-center bg-[var(--sb-bg)] px-4 py-10 ${className ?? \"\"}`}\n      >\n        <div className=\"flex w-full items-center justify-center text-[var(--sb-fg)]\">\n          <motion.span\n            ref={wrapperRef}\n            animate={shake}\n            className=\"relative inline-flex w-full shrink-0 justify-center\"\n          >\n            {/* Fires the moment the request resolves, and stays mounted\n                through the pill's collapse so the throw is never cut off\n                mid-arc. */}\n            <AnimatePresence>\n              {burst && (done || stage === \"joined\") && !reduced && (\n                <Streamers key=\"streamers\" origin={burst} />\n              )}\n            </AnimatePresence>\n\n            <motion.div\n              layout\n              style={{ borderRadius: RADIUS }}\n              /* Not a <button>: open, this box CONTAINS the submit button,\n                 and a button inside a button is invalid. Idle it takes the\n                 role instead, keyboard behaviour included; done it is a\n                 receipt, so it takes role=\"status\" and nothing is pressable. */\n              role={done ? \"status\" : expanded ? undefined : \"button\"}\n              tabIndex={expanded ? undefined : 0}\n              aria-label={expanded ? undefined : \"Subscribe to the newsletter\"}\n              onClick={expanded ? undefined : () => setStage(\"typing\")}\n              onKeyDown={(event) => {\n                if (expanded) return;\n                if (event.key === \"Enter\" || event.key === \" \") {\n                  event.preventDefault();\n                  setStage(\"typing\");\n                }\n              }}\n              /* Focus lands only once the box has finished growing. Focusing\n                 an input mid-morph makes the browser scroll to a rectangle\n                 that is still moving, which yanks the page. */\n              onLayoutAnimationComplete={() => {\n                if (stage === \"typing\") inputRef.current?.focus();\n              }}\n              whileTap={expanded ? undefined : { scale: 0.96 }}\n              transition={{ layout: travel, ...ARRIVE }}\n              className={[\n                \"flex h-[92px] shrink-0 items-center overflow-hidden bg-[var(--sb-pill)]\",\n                \"transition-colors duration-150 ease-out\",\n                \"outline-none focus-visible:ring-2 focus-visible:ring-[var(--sb-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--sb-bg)]\",\n                /* Three widths, not two: the pill opens wide for the field,\n                   pulls IN around the confirmation, then goes home. Each leg\n                   is the same node resizing, so the shrink reads as one object\n                   settling rather than three screens. */\n                open\n                  ? \"w-full max-w-[560px] gap-4 pr-3 pl-8\"\n                  : done\n                    ? \"w-[min(360px,82vw)] justify-center gap-3 px-8\"\n                    : \"w-[min(260px,64vw)] cursor-pointer justify-center gap-3 hover:bg-[var(--sb-pill-hover)]\",\n              ].join(\" \")}\n            >\n              {/* A fixed-size slot, so the glyph swap is a pure crossfade and\n                  the pill's width never depends on which icon is showing.\n                  layout=\"position\" keeps the glyph its true size while the\n                  pill morphs — without it the parent's scale correction\n                  stretches it for the length of the move. */}\n              <motion.span\n                layout=\"position\"\n                className=\"relative block h-[34px] w-[34px] shrink-0\"\n              >\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={done ? \"check\" : \"mail\"}\n                    className=\"absolute inset-0 grid place-items-center\"\n                    initial={{ opacity: 0, scale: 0.7, filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                    exit={{ opacity: 0, scale: 0.7, filter: \"blur(4px)\" }}\n                    transition={GLYPH}\n                  >\n                    {done ? (\n                      <IconCheck size={34} stroke={3} />\n                    ) : (\n                      <IconMail size={34} stroke={2.2} />\n                    )}\n                  </motion.span>\n                </AnimatePresence>\n              </motion.span>\n\n              {/* popLayout: the outgoing content leaves flow on frame one, so\n                  the surviving children reflow to their final positions\n                  immediately instead of after the fade. */}\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {stage === \"idle\" && (\n                  <motion.span\n                    key=\"cta\"\n                    layout=\"position\"\n                    className=\"text-[26px] font-bold tracking-tight whitespace-nowrap\"\n                    initial={{ opacity: 0, y: \"-0.5em\", filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                    exit={{\n                      opacity: 0,\n                      y: \"0.5em\",\n                      filter: \"blur(4px)\",\n                      transition: LEAVE,\n                    }}\n                    transition={ARRIVE_TEXT}\n                  >\n                    Subscribe\n                  </motion.span>\n                )}\n\n                {open && (\n                  /* The form wraps only the field group, not the pill: the\n                     pill outlives it. Enter still submits, which is the whole\n                     reason this is a <form> and not two divs. */\n                  <motion.form\n                    key=\"field\"\n                    layout=\"position\"\n                    onSubmit={submit}\n                    className=\"flex min-w-0 flex-1 items-center gap-3\"\n                    initial={{ opacity: 0, x: -12, filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                    exit={{\n                      opacity: 0,\n                      x: -12,\n                      filter: \"blur(4px)\",\n                      transition: LEAVE,\n                    }}\n                    transition={ARRIVE}\n                  >\n                    <input\n                      ref={inputRef}\n                      type=\"email\"\n                      name=\"email\"\n                      value={email}\n                      onChange={(event) => {\n                        setEmail(event.target.value);\n                        if (error) setError(null);\n                      }}\n                      disabled={stage !== \"typing\"}\n                      placeholder=\"you@example.com\"\n                      aria-label=\"Email address\"\n                      aria-invalid={error ? true : undefined}\n                      autoComplete=\"email\"\n                      /* min-w-0 lets the field claim its track from frame one\n                         and shrink honestly inside the flex row; the pill\n                         grows around it rather than the field pushing it. */\n                      className=\"min-w-0 flex-1 bg-transparent text-[26px] font-medium tracking-tight outline-none placeholder:text-[var(--sb-placeholder)]\"\n                    />\n\n                    {/* A fixed min-width, so the spinner mounting and the word\n                        growing from \"Join\" to \"Joining\" both recentre inside a\n                        button that never resizes — the pill above it stays\n                        perfectly still through the whole request. */}\n                    <button\n                      ref={buttonRef}\n                      type=\"submit\"\n                      disabled={stage !== \"typing\"}\n                      className=\"flex h-[68px] min-w-[152px] shrink-0 cursor-pointer items-center justify-center gap-2 bg-[var(--sb-button)] px-6 text-[24px] font-bold tracking-tight whitespace-nowrap text-[var(--sb-on-button)] transition-colors duration-150 ease-out outline-none hover:bg-[var(--sb-button-hover)] focus-visible:ring-2 focus-visible:ring-[var(--sb-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--sb-pill)] disabled:cursor-default\"\n                      style={{ borderRadius: RADIUS }}\n                    >\n                      {/* Only ever on screen while the request is in flight.\n                          popLayout so the label starts sliding back to centre\n                          on the frame the spinner leaves, not after it fades. */}\n                      <AnimatePresence mode=\"popLayout\" initial={false}>\n                        {stage === \"pending\" && (\n                          <motion.span\n                            key=\"spinner\"\n                            layout\n                            aria-hidden=\"true\"\n                            className=\"relative z-[1] block h-[18px] w-[18px] shrink-0\"\n                            initial={{ opacity: 0, scale: 0.3 }}\n                            animate={{ opacity: 1, scale: 1 }}\n                            exit={{\n                              opacity: 0,\n                              scale: 0.3,\n                              transition: LEAVE,\n                            }}\n                            /* Bounce on the scale only. Opacity riding a\n                               bouncy spring overshoots past opaque, which the\n                               browser clamps into a visible hitch. */\n                            transition={{ scale: SPINNER, opacity: SWAP }}\n                          >\n                            {/* The rotation is a CSS animation on an INNER\n                                node: `animate-spin` writes `transform`, and a\n                                CSS animation beats an inline style, so sharing\n                                a node with Motion's scale would silently drop\n                                the pop. Reduced motion keeps the ring and\n                                stops the spin. The ring is drawn in\n                                currentColor so it inherits the button's ink\n                                and flips with the theme. */}\n                            <span className=\"block h-full w-full animate-spin rounded-full border-2 border-current/25 border-t-current motion-reduce:animate-none motion-reduce:border-current/45 motion-reduce:border-t-current/45\" />\n                          </motion.span>\n                        )}\n                      </AnimatePresence>\n\n                      <ButtonLabel phase={stage} />\n                    </button>\n                  </motion.form>\n                )}\n\n                {done && (\n                  <motion.span\n                    key=\"done\"\n                    layout=\"position\"\n                    aria-live=\"polite\"\n                    className=\"text-[26px] font-bold tracking-tight whitespace-nowrap\"\n                    initial={{ opacity: 0, y: \"-0.5em\", filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                    exit={{\n                      opacity: 0,\n                      y: \"0.5em\",\n                      filter: \"blur(4px)\",\n                      transition: LEAVE,\n                    }}\n                    transition={ARRIVE_TEXT}\n                  >\n                    You&rsquo;re subscribed!\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </motion.div>\n\n            {/* Absolutely positioned: a validation message that pushes layout\n                would move the pill it is complaining about. */}\n            <AnimatePresence>\n              {error && (\n                <motion.p\n                  key=\"error\"\n                  role=\"alert\"\n                  className=\"absolute top-full left-1/2 mt-3 w-max -translate-x-1/2 text-[14px] font-medium text-[var(--sb-error)]\"\n                  initial={{ opacity: 0, y: -4, filter: \"blur(3px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{\n                    opacity: 0,\n                    y: -4,\n                    filter: \"blur(3px)\",\n                    transition: LEAVE,\n                  }}\n                  transition={ARRIVE_TEXT}\n                >\n                  {error}\n                </motion.p>\n              )}\n            </AnimatePresence>\n          </motion.span>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/subscribe-button/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Subscribe Button - Subscribe Button",
    "description": "A subscribe pill that morphs idle → field → confirmation → idle on one persistent `layout` node, with letter-by-letter label growth during the request and a self-dismissing receipt.",
    "category": "morphing-buttons",
    "tags": [
      "morph",
      "button",
      "subscribe",
      "newsletter",
      "email",
      "form",
      "input",
      "spinner",
      "streamers",
      "ribbons",
      "layout",
      "click",
      "spring",
      "dark-mode",
      "theming"
    ],
    "animationType": "click"
  }
}