{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-checkout",
  "type": "registry:component",
  "title": "Morphing Checkout Flow",
  "description": "A three-step card payment with a height:auto illusion, caret-safe masking, a 3D flip, and a FLIP pay button.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/lab/morphing-checkout/morphing-checkout.tsx",
      "type": "registry:component",
      "target": "@components/lab/morphing-checkout.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { MotionConfig, motion, useReducedMotion } from \"motion/react\";\n\n// Morphing checkout flow - a three-step card payment (card → billing → confirm)\n// where ONE container appears to animate `height: auto` between steps.\n//\n// The body's height is always an explicit px measured off the active panel\n// (useLayoutEffect + ResizeObserver); motion eases it. Everything else is\n// transform/opacity: the outgoing step exits softly while the incoming one's\n// fields cascade in, direction-aware. The other hard constraints:\n//\n//   · CARET-PRESERVING MASK. The number re-formats every keystroke (4-4-4-4, or\n//     4-6-5 for an Amex); the caret is put back by counting the DIGITS before it\n//     (the only characters the user owns), so it never jumps.\n//   · LUHN. The checksum validated live - a full number that fails shakes; one\n//     that passes gets a quiet green check.\n//   · 3D FLIP. Two stacked faces under perspective/preserve-3d/backface-hidden;\n//     focusing the CVC rotates the card 180° (Amex prints its code on the FRONT,\n//     so an Amex never flips - the detection is real).\n//   · PAY MORPH. Paying FLIPs the button's width to a circle (measured px → rem,\n//     imperative) while label → spinner → drawn check cross-fade. A decline\n//     lands a red drawn ✕ with one shake, then eases back for another try.\n//\n// Pass `onPay` to run the real charge. Animation via motion/react; honours\n// prefers-reduced-motion. Plain Tailwind only - no theme tokens or custom\n// classes required.\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst EASE_ICON = [0.2, 0, 0, 1] as const;\nconst STEP_NAMES = [\"card\", \"billing\", \"confirm\", \"paid\"];\n\nconst BRANDS: Record<string, { label: string; length: number; cvc: number; cvcName: string; groups: number[] }> = {\n  visa: { label: \"Visa\", length: 16, cvc: 3, cvcName: \"CVC\", groups: [4, 4, 4, 4] },\n  mastercard: { label: \"Mastercard\", length: 16, cvc: 3, cvcName: \"CVC\", groups: [4, 4, 4, 4] },\n  amex: { label: \"Amex\", length: 15, cvc: 4, cvcName: \"CID\", groups: [4, 6, 5] },\n  unknown: { label: \"Card\", length: 16, cvc: 3, cvcName: \"CVC\", groups: [4, 4, 4, 4] },\n};\n\nexport interface CheckoutState {\n  step: string;\n  brand: string;\n  number: string;\n  side: string;\n  status: string;\n  height: number | null;\n}\n\nconst detectBrand = (d: string) => (/^3[47]/.test(d) ? \"amex\" : /^4/.test(d) ? \"visa\" : /^(5[1-5]|2[2-7])/.test(d) ? \"mastercard\" : \"unknown\");\n\nfunction luhnValid(digits: string) {\n  let sum = 0;\n  for (let i = 0; i < digits.length; i++) {\n    let d = +digits[digits.length - 1 - i];\n    if (i % 2 === 1) {\n      d *= 2;\n      if (d > 9) d -= 9;\n    }\n    sum += d;\n  }\n  return digits.length > 0 && sum % 10 === 0;\n}\n\nfunction formatGroups(digits: string, groups: number[]) {\n  const out: string[] = [];\n  let i = 0;\n  for (const size of groups) {\n    if (i >= digits.length) break;\n    out.push(digits.slice(i, i + size));\n    i += size;\n  }\n  return out.join(\" \");\n}\n\nconst formatExpiry = (d: string) => (d.length <= 1 ? d : `${d.slice(0, 2)}/${d.slice(2)}`);\n\nfunction caretAfterDigit(masked: string, n: number) {\n  if (n <= 0) return 0;\n  let seen = 0;\n  for (let i = 0; i < masked.length; i++) {\n    if (/\\d/.test(masked[i])) {\n      seen += 1;\n      if (seen === n) return i + 1;\n    }\n  }\n  return masked.length;\n}\n\nfunction hopSeparators(event: React.KeyboardEvent<HTMLInputElement>) {\n  const el = event.currentTarget;\n  const { selectionStart, selectionEnd, value } = el;\n  if (selectionStart == null || selectionStart !== selectionEnd) return;\n  if (event.key === \"Backspace\" && /\\D/.test(value[selectionStart - 1] ?? \"\")) el.setSelectionRange(selectionStart - 1, selectionStart - 1);\n  else if (event.key === \"Delete\" && /\\D/.test(value[selectionStart] ?? \"\")) el.setSelectionRange(selectionStart + 1, selectionStart + 1);\n}\n\nconst reducedMotion = () => typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\nconst INPUT =\n  \"w-full h-10 px-3 rounded-lg bg-background text-foreground text-sm shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_-1px_rgba(0,0,0,0.06),0_2px_4px_0_rgba(0,0,0,0.04)] [transition:box-shadow_200ms_ease] placeholder:text-muted-foreground/70 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_1px_2px_-1px_rgba(0,0,0,0.08),0_2px_4px_0_rgba(0,0,0,0.06)] focus:outline focus:outline-2 focus:-outline-offset-1 focus:outline-ring aria-[invalid=true]:ring aria-[invalid=true]:ring-destructive/40 aria-[invalid=true]:shadow-[0_1px_2px_-1px_rgba(220,38,38,0.12)] aria-[invalid=true]:focus:outline-destructive\";\nconst LABEL = \"text-[0.6875rem] font-medium tracking-[0.02em] text-muted-foreground\";\n\nexport default function MorphingCheckout({\n  price = \"$149.00\",\n  morph = true,\n  inspect = false,\n  prefill = null,\n  outcome = \"success\",\n  indicator = \"tabs\",\n  onPay,\n  onStateChange,\n}: {\n  price?: string;\n  morph?: boolean;\n  inspect?: boolean;\n  prefill?: { key: number; number: string } | null;\n  /** \"success\" | \"decline\" - or return it from onPay. */\n  outcome?: \"success\" | \"decline\";\n  indicator?: \"tabs\" | \"bar\";\n  /** Your real charge. Return \"decline\" to fail; anything else succeeds. */\n  onPay?: (details: { number: string; expiry: string; cvc: string; name: string }) => Promise<\"success\" | \"decline\"> | void;\n  onStateChange?: (state: CheckoutState) => void;\n}) {\n  const [step, setStep] = useState(0);\n  const [leaving, setLeaving] = useState<{ step: number; dir: number } | null>(null);\n  const [dir, setDir] = useState(1);\n  const [animate, setAnimate] = useState(false);\n  const [maxStep, setMaxStep] = useState(0);\n\n  const [number, setNumber] = useState(\"\");\n  const [expiry, setExpiry] = useState(\"\");\n  const [cvcRaw, setCvcRaw] = useState(\"\");\n  const [name, setName] = useState(\"\");\n  const [address, setAddress] = useState(\"\");\n  const [city, setCity] = useState(\"\");\n  const [zip, setZip] = useState(\"\");\n\n  const [cvcFocus, setCvcFocus] = useState(false);\n  const [status, setStatus] = useState<\"idle\" | \"processing\" | \"failed\" | \"paid\">(\"idle\");\n  const [payError, setPayError] = useState<string | null>(null);\n  const [errors, setErrors] = useState<Record<string, string | null>>({});\n  const [navShake, setNavShake] = useState(false); // primary button shakes on failed validation, resets when done\n  const [numShake, setNumShake] = useState(false); // number field shakes on a failed checksum, resets when done\n  const [bodyH, setBodyH] = useState<number | null>(null);\n\n  const panelRef = useRef<HTMLDivElement>(null);\n  const payRef = useRef<HTMLButtonElement>(null);\n  const payIdleW = useRef(0);\n  const caretRef = useRef<{ node: HTMLInputElement; pos: number } | null>(null);\n  const leaveTimer = useRef<number>(0);\n  const timersRef = useRef<number[]>([]);\n  const bodyFirstRef = useRef(true);\n  const reduced = useReducedMotion();\n\n  const brand = detectBrand(number);\n  const spec = BRANDS[brand];\n  const cvc = cvcRaw.slice(0, spec.cvc);\n  const last4 = number.slice(-4);\n  const numberComplete = number.length === spec.length;\n  const numberValid = numberComplete && luhnValid(number);\n  const numberState = number.length === 0 ? \"empty\" : numberValid ? \"valid\" : numberComplete ? \"fails Luhn\" : \"incomplete\";\n  const expiryValid = (() => {\n    if (expiry.length !== 4) return false;\n    const mm = +expiry.slice(0, 2);\n    if (mm < 1 || mm > 12) return false;\n    const now = new Date();\n    const year = 2000 + +expiry.slice(2);\n    return year > now.getFullYear() || (year === now.getFullYear() && mm >= now.getMonth() + 1);\n  })();\n  const flipped = cvcFocus && brand !== \"amex\" && status === \"idle\";\n  const cascade = { animate, dir, reduced };\n\n  const clearError = (key: string) => setErrors((prev) => (prev[key] ? { ...prev, [key]: null } : prev));\n\n  function navigate(next: number, { force = false } = {}) {\n    if (next === step) return;\n    if (!force && status !== \"idle\") return;\n    const d = next > step ? 1 : -1;\n    if (morph && !reducedMotion()) {\n      setLeaving({ step, dir: d });\n      window.clearTimeout(leaveTimer.current);\n      leaveTimer.current = window.setTimeout(() => setLeaving(null), 240);\n      setAnimate(true);\n    } else {\n      setLeaving(null);\n      setAnimate(false);\n    }\n    setDir(d);\n    setStep(next);\n    setMaxStep((m) => Math.max(m, next));\n    requestAnimationFrame(() => panelRef.current?.querySelector(\"input\")?.focus({ preventScroll: true }));\n  }\n  const navigateRef = useRef(navigate);\n  navigateRef.current = navigate;\n\n  useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return undefined;\n    const measure = () => setBodyH(panel.offsetHeight);\n    measure();\n    requestAnimationFrame(() => {\n      bodyFirstRef.current = false;\n    });\n    const observer = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(measure) : null;\n    observer?.observe(panel);\n    return () => observer?.disconnect();\n  }, [step]);\n\n  useLayoutEffect(() => {\n    const pending = caretRef.current;\n    if (!pending) return;\n    caretRef.current = null;\n    if (pending.node && document.activeElement === pending.node) pending.node.setSelectionRange(pending.pos, pending.pos);\n  });\n\n  function handleNumberChange(event: React.ChangeEvent<HTMLInputElement>) {\n    const el = event.target;\n    const digitsBefore = el.value.slice(0, el.selectionStart ?? 0).replace(/\\D/g, \"\").length;\n    let digits = el.value.replace(/\\D/g, \"\");\n    const nextBrand = detectBrand(digits);\n    digits = digits.slice(0, BRANDS[nextBrand].length);\n    const masked = formatGroups(digits, BRANDS[nextBrand].groups);\n    caretRef.current = { node: el, pos: caretAfterDigit(masked, Math.min(digitsBefore, digits.length)) };\n    setNumber(digits);\n    clearError(\"number\");\n  }\n\n  function handleExpiryChange(event: React.ChangeEvent<HTMLInputElement>) {\n    const el = event.target;\n    let digitsBefore = el.value.slice(0, el.selectionStart ?? 0).replace(/\\D/g, \"\").length;\n    let digits = el.value.replace(/\\D/g, \"\").slice(0, 4);\n    if (digits.length === 1 && digits > \"1\") {\n      digits = `0${digits}`;\n      digitsBefore += 1;\n    }\n    const masked = formatExpiry(digits);\n    caretRef.current = { node: el, pos: caretAfterDigit(masked, Math.min(digitsBefore, digits.length)) };\n    setExpiry(digits);\n    clearError(\"expiry\");\n  }\n\n  useEffect(() => {\n    if (numberComplete && !numberValid) {\n      setErrors((prev) => ({ ...prev, number: \"This number fails\" }));\n      setNumShake(true);\n    }\n  }, [numberComplete, numberValid]);\n\n  function validateStep(current: number) {\n    const errs: Record<string, string> = {};\n    if (current === 0) {\n      if (!numberValid) errs.number = numberComplete ? \"This number fails\" : \"Enter the full card number\";\n      if (!expiryValid) errs.expiry = \"Enter a valid future date\";\n      if (cvc.length !== spec.cvc) errs.cvc = `Enter the ${spec.cvc}-digit ${spec.cvcName}`;\n    }\n    if (current === 1) {\n      if (!name.trim()) errs.name = \"Required\";\n      if (!address.trim()) errs.address = \"Required\";\n      if (!city.trim()) errs.city = \"Required\";\n      if (!zip.trim()) errs.zip = \"Required\";\n    }\n    return errs;\n  }\n\n  function handlePrimary() {\n    if (status !== \"idle\") return;\n    if (step === 2) {\n      pay();\n      return;\n    }\n    const errs = validateStep(step);\n    if (Object.values(errs).some(Boolean)) {\n      setErrors((prev) => ({ ...prev, ...errs }));\n      setNavShake(true);\n      requestAnimationFrame(() => panelRef.current?.querySelector<HTMLElement>('[aria-invalid=\"true\"]')?.focus({ preventScroll: true }));\n      return;\n    }\n    navigate(step + 1);\n  }\n\n  async function pay() {\n    const btn = payRef.current;\n    if (btn) {\n      payIdleW.current = btn.getBoundingClientRect().width;\n      btn.style.width = `${payIdleW.current}px`;\n      void btn.offsetWidth;\n      btn.style.width = \"2.75rem\";\n    }\n    setPayError(null);\n    setStatus(\"processing\");\n    const verdict = (await Promise.resolve(onPay?.({ number, expiry, cvc, name }))) ?? outcome;\n    timersRef.current.push(\n      window.setTimeout(() => {\n        if (verdict === \"decline\") {\n          setStatus(\"failed\");\n          timersRef.current.push(\n            window.setTimeout(() => {\n              if (payRef.current) payRef.current.style.width = `${payIdleW.current}px`;\n              setStatus(\"idle\");\n              setPayError(\"Your card was declined. Try a different card.\");\n              timersRef.current.push(\n                window.setTimeout(() => {\n                  if (payRef.current) payRef.current.style.width = \"\";\n                }, 450),\n              );\n            }, 1400),\n          );\n          return;\n        }\n        setStatus(\"paid\");\n        timersRef.current.push(window.setTimeout(() => navigateRef.current(3, { force: true }), 1000));\n      }, 1500),\n    );\n  }\n\n  function reset() {\n    timersRef.current.forEach(window.clearTimeout);\n    timersRef.current = [];\n    if (payRef.current) payRef.current.style.width = \"\";\n    setStatus(\"idle\");\n    setPayError(null);\n    setNumber(\"\");\n    setExpiry(\"\");\n    setCvcRaw(\"\");\n    setName(\"\");\n    setAddress(\"\");\n    setCity(\"\");\n    setZip(\"\");\n    setErrors({});\n    setMaxStep(0);\n    navigate(0, { force: true });\n  }\n\n  useEffect(() => {\n    if (!prefill?.key) return;\n    timersRef.current.forEach(window.clearTimeout);\n    timersRef.current = [];\n    if (payRef.current) payRef.current.style.width = \"\";\n    const digits = String(prefill.number ?? \"\").replace(/\\D/g, \"\");\n    const b = detectBrand(digits);\n    setStatus(\"idle\");\n    setPayError(null);\n    setNumber(digits.slice(0, BRANDS[b].length));\n    setExpiry(\"1229\");\n    setCvcRaw(b === \"amex\" ? \"4424\" : \"442\");\n    setErrors({});\n    navigateRef.current(0, { force: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [prefill?.key]);\n\n  useEffect(\n    () => () => {\n      timersRef.current.forEach(window.clearTimeout);\n      window.clearTimeout(leaveTimer.current);\n    },\n    [],\n  );\n\n  useEffect(() => {\n    onStateChange?.({\n      step: STEP_NAMES[step],\n      brand,\n      number: numberState,\n      side: flipped ? \"back\" : \"front\",\n      status,\n      height: bodyH == null ? null : Math.round(bodyH),\n    });\n  }, [step, brand, numberState, flipped, status, bodyH, onStateChange]);\n\n  function renderStep(s: number, ghost = false) {\n    const id = (base: string) => (ghost ? `${base}-ghost` : base);\n    if (s === 0) {\n      return (\n        <>\n          <StepChild i={0} cascade={cascade} className=\"flex flex-col gap-1.5\">\n            <label className={LABEL} htmlFor={id(\"mc-number\")}>Card number</label>\n            <motion.div className=\"relative\" animate={numShake && !ghost ? { x: [0, -4, 4, -4, 4, 0] } : { x: 0 }} transition={{ duration: 0.32, ease: \"easeInOut\" }} onAnimationComplete={() => { if (numShake && !ghost) setNumShake(false); }}>\n              <input\n                id={id(\"mc-number\")}\n                className={`${INPUT} tabular-nums`}\n                type=\"text\"\n                value={formatGroups(number, spec.groups)}\n                onChange={handleNumberChange}\n                onKeyDown={hopSeparators}\n                placeholder={brand === \"amex\" ? \"3782 822463 10005\" : \"4242 4242 4242 4242\"}\n                inputMode=\"numeric\"\n                autoComplete=\"cc-number\"\n                spellCheck={false}\n                aria-invalid={errors.number ? \"true\" : undefined}\n                aria-describedby={errors.number ? id(\"mc-number-err\") : undefined}\n              />\n              <motion.span\n                className=\"absolute right-2.5 top-1/2 -translate-y-1/2 inline-flex text-[#16a34a] pointer-events-none\"\n                initial={false}\n                animate={numberValid ? { opacity: 1, scale: 1, filter: \"blur(0px)\" } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                transition={{ duration: 0.25, ease: EASE_ICON }}\n              >\n                <ValidIcon />\n              </motion.span>\n              {inspect && !ghost && <SpecLabel className=\"bottom-[calc(100%+0.25rem)] right-0 border-[#fecaca] text-[#dc2626]\">groups {spec.groups.join(\"-\")} · caret by digit index</SpecLabel>}\n            </motion.div>\n            {errors.number && <ErrorMsg id={id(\"mc-number-err\")}>{errors.number}</ErrorMsg>}\n          </StepChild>\n          <StepChild i={1} cascade={cascade} className=\"grid grid-cols-2 gap-3\">\n            <div className=\"flex flex-col gap-1.5\">\n              <label className={LABEL} htmlFor={id(\"mc-expiry\")}>Expiry</label>\n              <input id={id(\"mc-expiry\")} className={`${INPUT} tabular-nums`} type=\"text\" value={formatExpiry(expiry)} onChange={handleExpiryChange} onKeyDown={hopSeparators} placeholder=\"MM/YY\" inputMode=\"numeric\" autoComplete=\"cc-exp\" spellCheck={false} aria-invalid={errors.expiry ? \"true\" : undefined} aria-describedby={errors.expiry ? id(\"mc-expiry-err\") : undefined} />\n              {errors.expiry && <ErrorMsg id={id(\"mc-expiry-err\")}>{errors.expiry}</ErrorMsg>}\n            </div>\n            <div className=\"flex flex-col gap-1.5\">\n              <label className={LABEL} htmlFor={id(\"mc-cvc\")}>{spec.cvcName}</label>\n              <input id={id(\"mc-cvc\")} className={`${INPUT} tabular-nums`} type=\"text\" value={cvc} onChange={(e) => { setCvcRaw(e.target.value.replace(/\\D/g, \"\").slice(0, spec.cvc)); clearError(\"cvc\"); }} onFocus={() => setCvcFocus(true)} onBlur={() => setCvcFocus(false)} placeholder={\"•\".repeat(spec.cvc)} inputMode=\"numeric\" autoComplete=\"cc-csc\" spellCheck={false} aria-invalid={errors.cvc ? \"true\" : undefined} aria-describedby={errors.cvc ? id(\"mc-cvc-err\") : undefined} />\n              {errors.cvc && <ErrorMsg id={id(\"mc-cvc-err\")}>{errors.cvc}</ErrorMsg>}\n            </div>\n          </StepChild>\n        </>\n      );\n    }\n    if (s === 1) {\n      const field = (i: number, key: string, label: string, val: string, set: (v: string) => void, placeholder: string, ac: string, numeric = false) => (\n        <StepChild i={i} cascade={cascade} className=\"flex flex-col gap-1.5\">\n          <label className={LABEL} htmlFor={id(`mc-${key}`)}>{label}</label>\n          <input id={id(`mc-${key}`)} className={numeric ? `${INPUT} tabular-nums` : INPUT} type=\"text\" value={val} onChange={(e) => { set(e.target.value); clearError(key); }} placeholder={placeholder} autoComplete={ac} spellCheck={false} aria-invalid={errors[key] ? \"true\" : undefined} />\n          {errors[key] && <ErrorMsg>{errors[key]}</ErrorMsg>}\n        </StepChild>\n      );\n      return (\n        <>\n          {field(0, \"name\", \"Name on card\", name, setName, \"Ada Lovelace\", \"cc-name\")}\n          {field(1, \"address\", \"Street address\", address, setAddress, \"42 Analytical Engine Way\", \"street-address\")}\n          <StepChild i={2} cascade={cascade} className=\"grid grid-cols-2 gap-3\">\n            <div className=\"flex flex-col gap-1.5\">\n              <label className={LABEL} htmlFor={id(\"mc-city\")}>City</label>\n              <input id={id(\"mc-city\")} className={INPUT} type=\"text\" value={city} onChange={(e) => { setCity(e.target.value); clearError(\"city\"); }} placeholder=\"London\" autoComplete=\"address-level2\" spellCheck={false} aria-invalid={errors.city ? \"true\" : undefined} />\n              {errors.city && <ErrorMsg>{errors.city}</ErrorMsg>}\n            </div>\n            <div className=\"flex flex-col gap-1.5\">\n              <label className={LABEL} htmlFor={id(\"mc-zip\")}>ZIP</label>\n              <input id={id(\"mc-zip\")} className={`${INPUT} tabular-nums`} type=\"text\" value={zip} onChange={(e) => { setZip(e.target.value); clearError(\"zip\"); }} placeholder=\"10118\" autoComplete=\"postal-code\" spellCheck={false} aria-invalid={errors.zip ? \"true\" : undefined} />\n              {errors.zip && <ErrorMsg>{errors.zip}</ErrorMsg>}\n            </div>\n          </StepChild>\n        </>\n      );\n    }\n    if (s === 2) {\n      return (\n        <>\n          <StepChild i={0} cascade={cascade}>\n            <dl className=\"flex flex-col\">\n              <SumRow dt=\"Card\">{spec.label} •••• {last4}</SumRow>\n              <SumRow dt=\"Expires\"><span className=\"tabular-nums\">{formatExpiry(expiry)}</span></SumRow>\n              <SumRow dt=\"Name\">{name.trim()}</SumRow>\n              <SumRow dt=\"Billing\">{address.trim()}, {city.trim()} {zip.trim()}</SumRow>\n              <SumRow dt=\"Total\" total><span className=\"tabular-nums\">{price}</span></SumRow>\n            </dl>\n          </StepChild>\n          <StepChild i={1} cascade={cascade}><p className=\"text-xs text-muted-foreground/70\">Demo checkout - nothing is charged.</p></StepChild>\n        </>\n      );\n    }\n    return (\n      <>\n        <StepChild i={0} cascade={cascade}>\n          <div>\n            <h3 className=\"text-[0.9375rem] font-semibold text-foreground\">Payment complete</h3>\n            <p className=\"mt-1.5 text-[0.8125rem] text-muted-foreground\">Charged <span className=\"tabular-nums\">{price}</span> to {spec.label} •••• {last4}</p>\n          </div>\n        </StepChild>\n        <StepChild i={1} cascade={cascade}><p className=\"text-xs text-muted-foreground/70\">A receipt is on its way to your inbox. Probably.</p></StepChild>\n      </>\n    );\n  }\n\n  const tabs = [\"Card\", \"Billing\", \"Confirm\"];\n  const p = (Math.min(step, 2) + 1) / 3;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"w-full max-w-[22rem] relative\">\n        <div className=\"relative p-4 rounded-3xl bg-card shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_-1px_rgba(0,0,0,0.06),0_2px_4px_0_rgba(0,0,0,0.04)]\">\n          {/* Live preview - presentational, hidden from AT. */}\n          <div className=\"relative [perspective:62.5rem] mb-4\">\n            <motion.div className=\"relative aspect-[1.586] [transform-style:preserve-3d]\" animate={{ rotateY: flipped ? 180 : 0 }} transition={{ duration: 0.6, ease: EASE }} aria-hidden=\"true\">\n              <div className=\"absolute inset-0 flex flex-col [backface-visibility:hidden] rounded-lg overflow-hidden text-[#111] bg-[linear-gradient(135deg,#f9fafb,#f0f1f3_55%,#f6f7f8)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_-3px_rgba(0,0,0,0.08)] p-[1.125rem] justify-between\">\n                <div className=\"flex items-start justify-between\">\n                  <span className=\"w-8 h-6 rounded-[0.375rem] bg-[linear-gradient(135deg,#e4e4e7,#c6c6cc)]\" />\n                  <span className=\"grid place-items-center min-w-12 h-6\">\n                    <BrandMark active={brand === \"unknown\"} className=\"text-black/30\"><GenericCardIcon /></BrandMark>\n                    <BrandMark active={brand === \"visa\"} className=\"text-[0.9375rem] font-extrabold italic tracking-[0.04em]\">VISA</BrandMark>\n                    <BrandMark active={brand === \"mastercard\"}><i className=\"w-[1.125rem] h-[1.125rem] rounded-full bg-[#3f3f46]\" /><i className=\"w-[1.125rem] h-[1.125rem] rounded-full bg-[#a1a1aa] opacity-90 -ml-[0.4375rem]\" /></BrandMark>\n                    <BrandMark active={brand === \"amex\"} className=\"px-[0.3125rem] py-0.5 rounded bg-[#3f3f46] text-white text-[0.5625rem] font-bold tracking-[0.08em]\">AMEX</BrandMark>\n                  </span>\n                </div>\n                {brand === \"amex\" && (\n                  <div className=\"absolute top-[3.25rem] right-[1.125rem] flex items-baseline gap-1.5 text-[0.6875rem]\">\n                    <span className={MINI}>CID</span>\n                    <span className=\"tabular-nums\"><PopChars value={cvc.padEnd(spec.cvc, \"•\")} /></span>\n                  </div>\n                )}\n                <div className=\"flex gap-[0.75ch] text-[1.0625rem] tabular-nums tracking-[0.06em] [text-shadow:0_1px_0_rgba(255,255,255,0.8)]\">\n                  {(() => {\n                    let consumed = 0;\n                    return spec.groups.map((size, gi) => {\n                      const chunk = number.slice(consumed, consumed + size).padEnd(size, \"•\");\n                      consumed += size;\n                      return <span className=\"inline-flex\" key={gi}><PopChars value={chunk} /></span>;\n                    });\n                  })()}\n                </div>\n                <div className=\"flex items-end justify-between gap-4\">\n                  <span className=\"min-w-0 overflow-hidden text-[0.6875rem] font-medium tracking-[0.12em] whitespace-nowrap\"><PopChars value={(name.trim() || \"Your name\").toUpperCase()} /></span>\n                  <span className=\"flex items-baseline gap-1.5 text-[0.6875rem] whitespace-nowrap\">\n                    <span className={MINI}>Valid thru</span>\n                    <span className=\"tabular-nums\"><PopChars value={expiry ? formatExpiry(expiry).padEnd(5, \"•\") : \"••/••\"} /></span>\n                  </span>\n                </div>\n              </div>\n              <div className=\"absolute inset-0 flex flex-col [backface-visibility:hidden] rounded-lg overflow-hidden bg-[linear-gradient(135deg,#f9fafb,#f0f1f3_55%,#f6f7f8)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_-3px_rgba(0,0,0,0.08)] [transform:rotateY(180deg)]\">\n                <div className=\"h-9 mt-[1.125rem] bg-[#d4d4d8]\" />\n                <div className=\"flex items-center justify-end h-7 mx-[1.125rem] mt-4 px-2.5 rounded [background:repeating-linear-gradient(0deg,#fff,#fff_3px,#f0f1f3_3px,#f0f1f3_4px)] shadow-[0_0_0_1px_rgba(0,0,0,0.06)] text-[#111] text-[0.8125rem] italic\">\n                  <span className=\"tabular-nums\"><PopChars value={cvc.padEnd(spec.cvc, \"•\")} /></span>\n                </div>\n                <p className=\"my-2 mx-[1.125rem] text-[0.5625rem] tracking-[0.08em] uppercase text-black/40\">Security code</p>\n              </div>\n            </motion.div>\n            {inspect && (\n              <>\n                <span className=\"absolute inset-0 z-[5] pointer-events-none border-[1.5px] border-dashed border-[#ef4444] rounded-lg\" />\n                <SpecLabel className=\"top-2 left-1/2 -translate-x-1/2 border-[#fecaca] text-[#dc2626]\">rotateY({flipped ? \"180\" : \"0\"}deg) · preserve-3d</SpecLabel>\n              </>\n            )}\n          </div>\n\n          {/* Step indicator */}\n          {indicator === \"bar\" ? (\n            <nav className=\"mb-4\" aria-label=\"Checkout steps\">\n              <div className=\"grid grid-cols-3\">\n                {tabs.map((label, index) => (\n                  <button key={label} type=\"button\" className={`relative h-8 rounded-md text-xs font-medium [transition:color_200ms_ease] after:content-[''] after:absolute after:inset-x-0 after:-inset-y-1 ${step === index ? \"text-foreground\" : index > maxStep ? \"text-muted-foreground/70\" : \"text-muted-foreground\"} enabled:hover:text-foreground disabled:cursor-default focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring`} aria-current={step === index ? \"step\" : undefined} disabled={index === step || index > maxStep || status !== \"idle\"} onClick={() => navigate(index)}>\n                    {label}\n                  </button>\n                ))}\n              </div>\n              <div className=\"h-1 mt-1.5 rounded-full bg-muted overflow-hidden\" aria-hidden=\"true\">\n                <motion.span className={`block h-full rounded-full ${step === 3 ? \"bg-[#16a34a]\" : \"bg-primary\"}`} animate={{ x: `${(1 - p) * -100}%` }} transition={morph && !reduced ? { duration: 0.38, ease: EASE } : { duration: 0 }} />\n              </div>\n            </nav>\n          ) : (\n            <nav className=\"relative grid grid-cols-3 p-1 mb-4 rounded-full bg-muted\" aria-label=\"Checkout steps\">\n              <motion.span className=\"absolute top-1 bottom-1 left-1 w-[calc((100%-0.5rem)/3)] rounded-full bg-background shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_-1px_rgba(0,0,0,0.06),0_2px_4px_0_rgba(0,0,0,0.04)]\" aria-hidden=\"true\" animate={{ x: `${Math.min(step, 2) * 100}%` }} transition={morph && !reduced ? { duration: 0.38, ease: EASE } : { duration: 0 }} />\n              {tabs.map((label, index) => (\n                <button key={label} type=\"button\" className={`relative z-[1] h-8 rounded-full text-xs font-medium [transition:color_200ms_ease] after:content-[''] after:absolute after:inset-x-0 after:-inset-y-1 ${step === index ? \"text-foreground\" : \"text-muted-foreground\"} enabled:hover:text-foreground disabled:cursor-default focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring`} aria-current={step === index ? \"step\" : undefined} disabled={index === step || index > maxStep || status !== \"idle\"} onClick={() => navigate(index)}>\n                  {label}\n                </button>\n              ))}\n            </nav>\n          )}\n\n          <motion.div className=\"relative overflow-hidden\" animate={{ height: bodyH ?? \"auto\" }} transition={!morph || reduced || bodyFirstRef.current ? { duration: 0 } : { duration: 0.38, ease: EASE }}>\n            {leaving && (\n              <motion.div className=\"absolute top-0 left-0 right-0 flex flex-col gap-3.5 p-0.5 pointer-events-none\" initial={{ opacity: 1, x: 0 }} animate={{ opacity: 0, x: leaving.dir * -12, filter: \"blur(2px)\" }} transition={{ duration: 0.18, ease: \"easeIn\" }} aria-hidden=\"true\" inert>\n                {renderStep(leaving.step, true)}\n              </motion.div>\n            )}\n            <div key={step} ref={panelRef} className=\"flex flex-col gap-3.5 p-0.5\">\n              {renderStep(step)}\n            </div>\n            {inspect && (\n              <>\n                <span className=\"absolute inset-0 z-[5] pointer-events-none border-[1.5px] border-dashed border-[#3b82f6] rounded-lg\" />\n                <SpecLabel className=\"bottom-1 right-1 border-[#bfdbfe] text-[#2563eb]\">height: {bodyH == null ? \"auto\" : `${Math.round(bodyH)}px`} · measured → eased</SpecLabel>\n              </>\n            )}\n          </motion.div>\n\n          <div className=\"relative flex items-center justify-between gap-3 min-h-11 mt-4\">\n            <button\n              type=\"button\"\n              className={`h-11 px-3.5 rounded-full text-[0.8125rem] font-medium text-muted-foreground [transition:color_200ms_ease,background-color_200ms_ease,opacity_250ms_cubic-bezier(0.2,0,0,1),scale_150ms_ease-out] hover:text-foreground hover:bg-accent active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${step === 0 || (status !== \"idle\" && step !== 3) ? \"opacity-0 pointer-events-none\" : \"opacity-100\"}`}\n              tabIndex={step === 0 || (status !== \"idle\" && step !== 3) ? -1 : 0}\n              onClick={() => (step === 3 ? reset() : navigate(step - 1))}\n            >\n              {step === 3 ? \"Start over\" : \"Back\"}\n            </button>\n            <motion.div className=\"ml-auto\" animate={navShake || status === \"failed\" ? { x: [0, -4, 4, -4, 4, 0] } : { x: 0 }} transition={{ duration: 0.32, ease: \"easeInOut\", delay: status === \"failed\" ? 0.2 : 0 }} onAnimationComplete={() => setNavShake(false)}>\n              <button\n                type=\"button\"\n                ref={payRef}\n                className=\"relative grid place-items-center h-11 min-w-11 rounded-full overflow-hidden whitespace-nowrap bg-primary text-primary-foreground [transition:width_420ms_cubic-bezier(0.22,1,0.36,1),background-color_300ms_ease,scale_150ms_ease-out] active:enabled:scale-[0.96] disabled:cursor-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[status=paid]:bg-[#16a34a] data-[status=failed]:bg-destructive\"\n                data-status={status}\n                disabled={status !== \"idle\"}\n                aria-busy={status === \"processing\"}\n                onClick={handlePrimary}\n              >\n                <motion.span className=\"[grid-area:1/1] inline-flex items-center gap-[0.4375rem] px-5 text-sm font-medium\" initial={false} animate={status === \"idle\" ? { opacity: 1, scale: 1, filter: \"blur(0px)\" } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }} transition={{ duration: 0.25, ease: EASE_ICON }}>\n                  {step >= 2 ? (<><LockIcon /> Pay <span className=\"tabular-nums\">{price}</span></>) : \"Continue\"}\n                </motion.span>\n                <PayIcon show={status === \"processing\"}>\n                  <motion.span className=\"w-[1.125rem] h-[1.125rem] rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground\" animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 0.7, ease: \"linear\" }} />\n                </PayIcon>\n                <PayIcon show={status === \"paid\"}>\n                  <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n                    <motion.path d=\"M5 13l4 4L19 7\" initial={{ pathLength: 0 }} animate={{ pathLength: status === \"paid\" ? 1 : 0 }} transition={{ duration: 0.32, delay: 0.12, ease: EASE }} />\n                  </svg>\n                </PayIcon>\n                <PayIcon show={status === \"failed\"}>\n                  <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" aria-hidden=\"true\">\n                    <motion.path d=\"M7 7l10 10\" initial={{ pathLength: 0 }} animate={{ pathLength: status === \"failed\" ? 1 : 0 }} transition={{ duration: 0.18, delay: 0.12, ease: EASE }} />\n                    <motion.path d=\"M17 7L7 17\" initial={{ pathLength: 0 }} animate={{ pathLength: status === \"failed\" ? 1 : 0 }} transition={{ duration: 0.18, delay: 0.26, ease: EASE }} />\n                  </svg>\n                </PayIcon>\n              </button>\n            </motion.div>\n            {inspect && status !== \"idle\" && <SpecLabel className=\"bottom-[calc(100%+0.4rem)] right-0 border-[#fecaca] text-[#dc2626]\">width: measured px → 2.75rem</SpecLabel>}\n          </div>\n\n          {payError && <motion.p className=\"mt-3 text-xs text-destructive\" role=\"alert\" initial={{ opacity: 0, y: \"-0.25rem\" }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: \"easeOut\" }}>{payError}</motion.p>}\n        </div>\n\n        <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          {status === \"processing\" ? \"Processing payment\" : status === \"paid\" ? \"Payment complete\" : status === \"failed\" ? \"Payment declined\" : `Step ${Math.min(step, 2) + 1} of 3: ${[\"card details\", \"billing address\", \"confirm and pay\"][Math.min(step, 2)]}`}\n        </p>\n      </div>\n    </MotionConfig>\n  );\n}\n\n// Direction-aware field cascade. Motion when animating, plain otherwise.\n// Module-level so its identity is stable across renders - nesting it inside the\n// component would create a new function every keystroke, remounting the inputs\n// and stealing focus.\nfunction StepChild({\n  i,\n  cascade,\n  className,\n  children,\n}: {\n  i: number;\n  cascade: { animate: boolean; dir: number; reduced: boolean | null };\n  className?: string;\n  children: ReactNode;\n}) {\n  if (!cascade.animate || cascade.reduced) return <div className={className}>{children}</div>;\n  return (\n    <motion.div\n      className={className}\n      initial={{ opacity: 0, x: cascade.dir * 16, filter: \"blur(3px)\" }}\n      animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n      transition={{ duration: 0.38, ease: EASE, delay: i * 0.04 }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction ErrorMsg({ id, children }: { id?: string; children: ReactNode }) {\n  return (\n    <motion.p className=\"text-xs text-destructive\" id={id} initial={{ opacity: 0, y: \"-0.25rem\" }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: \"easeOut\" }}>\n      {children}\n    </motion.p>\n  );\n}\n\nconst MINI = \"text-[0.5rem] font-medium tracking-[0.1em] uppercase text-black/40\";\n\nfunction BrandMark({ active, className = \"\", children }: { active: boolean; className?: string; children: ReactNode }) {\n  return (\n    <motion.span className={`[grid-area:1/1] inline-flex items-center ${className}`} initial={false} animate={active ? { opacity: 1, scale: 1, filter: \"blur(0px)\" } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }} transition={{ duration: 0.25, ease: EASE_ICON }}>\n      {children}\n    </motion.span>\n  );\n}\n\nfunction PayIcon({ show, children }: { show: boolean; children: ReactNode }) {\n  return (\n    <motion.span className=\"absolute left-1/2 top-1/2 inline-flex\" style={{ x: \"-50%\", y: \"-50%\" } as CSSProperties} initial={false} animate={show ? { opacity: 1, scale: 1, filter: \"blur(0px)\" } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }} transition={{ duration: 0.25, ease: EASE_ICON }} aria-hidden=\"true\">\n      {children}\n    </motion.span>\n  );\n}\n\nfunction SumRow({ dt, total, children }: { dt: string; total?: boolean; children: ReactNode }) {\n  return (\n    <div className={`flex justify-between gap-4 py-[0.4375rem] text-[0.8125rem] [&+&]:border-t [&+&]:border-border ${total ? \"font-semibold\" : \"\"}`}>\n      <dt className={total ? \"text-foreground\" : \"text-muted-foreground\"}>{dt}</dt>\n      <dd className={`min-w-0 text-right [overflow-wrap:anywhere] ${total ? \"text-foreground\" : \"text-foreground font-medium\"}`}>{children}</dd>\n    </div>\n  );\n}\n\nfunction SpecLabel({ className = \"\", children }: { className?: string; children: ReactNode }) {\n  return <span className={`absolute z-[6] whitespace-nowrap rounded-[0.25rem] border bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums ${className}`}>{children}</span>;\n}\n\n// Embossed card repaints per character: each span keyed by position + value, so\n// a changed character remounts and pops in while tabular neighbours hold still.\nfunction PopChars({ value }: { value: string }) {\n  return (\n    <>\n      {value.split(\"\").map((char, index) => {\n        const dim = char === \"•\";\n        if (dim) return <span key={`${index}-${char}`} className=\"inline-block min-w-[1ch] text-center text-black/[0.28]\">{char}</span>;\n        return (\n          <motion.span key={`${index}-${char}`} className=\"inline-block min-w-[1ch] text-center\" initial={{ opacity: 0, y: \"0.3em\", scale: 0.9, filter: \"blur(2px)\" }} animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }} transition={{ duration: 0.24, ease: EASE }}>\n            {char === \" \" ? \" \" : char}\n          </motion.span>\n        );\n      })}\n    </>\n  );\n}\n\nfunction GenericCardIcon() { return <svg width=\"22\" height=\"16\" viewBox=\"0 0 22 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\"><rect x=\"1\" y=\"1\" width=\"20\" height=\"14\" rx=\"2.5\" /><line x1=\"1\" y1=\"5.5\" x2=\"21\" y2=\"5.5\" /></svg>; }\nfunction ValidIcon() { return <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 13l4 4L19 7\" /></svg>; }\nfunction LockIcon() { return <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><rect x=\"4\" y=\"11\" width=\"16\" height=\"10\" rx=\"2\" /><path d=\"M8 11V7a4 4 0 0 1 8 0v4\" /></svg>; }\n"
    }
  ]
}
