{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quench",
  "type": "registry:ui",
  "title": "Quench",
  "description": "A click ripple that expands from the exact point of contact, like hot metal hitting water: a thin shockwave, a briefly desaturated cooled core, and ejected spark particles. Keyboard activation ripples from center.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/buttons/quench/quench.tsx",
      "type": "registry:ui",
      "target": "components/crucible/quench.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, type HTMLMotionProps } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface QuenchProps extends Omit<HTMLMotionProps<\"button\">, \"onClick\" | \"children\"> {\n  /** Shockwave color. Any CSS color. @default white */\n  color?: string;\n  /** Seconds the ripple takes to fully dissipate. @default 0.7 */\n  duration?: number;\n  /** Show three spark particles ejected from the click point. @default true */\n  sparks?: boolean;\n  /** Fires after the ripple's originating click/keyboard-activation. */\n  onClick?: React.MouseEventHandler<HTMLButtonElement>;\n  /** Button label / content. */\n  children?: React.ReactNode;\n}\n\ninterface Ripple {\n  id: number;\n  x: number;\n  y: number;\n  size: number;\n  /** True when triggered by keyboard activation (no pointer coordinates available). */\n  keyboard: boolean;\n}\n\n/**\n * Quench — a click ripple that expands from the exact point of contact: a\n * thin white shockwave, a briefly desaturated \"cooled\" interior, and a\n * scatter of white spark particles. At rest the face carries a soft top\n * light and a slow-breathing edge glow; hovering primes it — the edge\n * light warms up on a spring before the strike. Keyboard activation\n * (Enter/Space) ripples from the button's center. Falls back to a plain\n * opacity pulse under prefers-reduced-motion.\n */\nexport function Quench({\n  color = \"#ffffff\",\n  duration = 0.7,\n  sparks = true,\n  className,\n  style,\n  onClick,\n  children,\n  ...rest\n}: QuenchProps) {\n  const reducedMotion = useReducedMotion();\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const [ripples, setRipples] = React.useState<Ripple[]>([]);\n  const nextId = React.useRef(0);\n  const timers = React.useRef(new Set<number>());\n  React.useEffect(() => {\n    const pending = timers.current;\n    return () => {\n      pending.forEach((t) => window.clearTimeout(t));\n      pending.clear();\n    };\n  }, []);\n\n  const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {\n    const el = buttonRef.current;\n    if (el) {\n      const rect = el.getBoundingClientRect();\n      // A keyboard-activated click carries no real pointer position (browsers\n      // report clientX/clientY as 0, and detail is 0 for non-pointer clicks).\n      const isKeyboard = event.detail === 0;\n      const x = isKeyboard ? rect.width / 2 : event.clientX - rect.left;\n      const y = isKeyboard ? rect.height / 2 : event.clientY - rect.top;\n      const size = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2.2;\n\n      const id = nextId.current++;\n      setRipples((prev) => [...prev, { id, x, y, size, keyboard: isKeyboard }]);\n      // The shockwave ring lives slightly past `duration` (1.2x) so it reads\n      // fully before unmount.\n      const timer = window.setTimeout(() => {\n        timers.current.delete(timer);\n        setRipples((prev) => prev.filter((r) => r.id !== id));\n      }, duration * 1200);\n      timers.current.add(timer);\n    }\n    onClick?.(event);\n  };\n\n  return (\n    <motion.button\n      ref={buttonRef}\n      data-crucible=\"quench\"\n      initial=\"idle\"\n      animate=\"idle\"\n      whileHover=\"hover\"\n      whileTap={reducedMotion ? undefined : { scale: 0.97 }}\n      className={cn(\n        \"relative isolate inline-flex items-center justify-center overflow-hidden rounded-lg\",\n        \"border border-neutral-800 bg-neutral-900 px-6 py-3 text-sm font-medium text-white\",\n        \"select-none transition-colors duration-200\",\n        \"hover:border-[color:var(--quench-hover-border)] hover:bg-neutral-800\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n        \"focus-visible:ring-[color:var(--quench-color)]\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      style={\n        {\n          \"--quench-color\": color,\n          \"--quench-hover-border\": `color-mix(in oklab, ${color} 45%, #262626)`,\n          ...style,\n        } as React.CSSProperties\n      }\n      onClick={handleClick}\n      {...rest}\n    >\n      {/* Idle life: a soft top light so the face never reads as a flat slab. */}\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          background:\n            \"radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,0.07), transparent 55%)\",\n          boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.07), inset 0 -1px 0 rgba(0,0,0,0.35)\",\n        }}\n      />\n      {/* Barely-breathing edge glow (static under reduced motion). */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 12%, transparent), inset 0 -8px 18px -12px color-mix(in oklab, ${color} 22%, transparent)`,\n        }}\n        animate={reducedMotion ? { opacity: 0.7 } : { opacity: [0.4, 0.85, 0.4] }}\n        transition={\n          reducedMotion\n            ? { duration: 0.2 }\n            : { duration: 4.4, repeat: Infinity, ease: \"easeInOut\" }\n        }\n      />\n      {/* Hover primer: the edge light warms up before the strike. */}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n        style={{\n          background: `radial-gradient(130% 100% at 50% 0%, color-mix(in oklab, ${color} 9%, transparent), transparent 60%)`,\n          boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color} 38%, transparent), inset 0 -12px 24px -14px color-mix(in oklab, ${color} 32%, transparent), inset 0 1px 0 color-mix(in oklab, ${color} 30%, transparent)`,\n        }}\n        variants={{ idle: { opacity: 0 }, hover: { opacity: 1 } }}\n        transition={\n          reducedMotion\n            ? { duration: 0.2, ease: \"easeOut\" }\n            : { type: \"spring\", stiffness: 260, damping: 28 }\n        }\n      />\n      <span aria-hidden className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n        <AnimatePresence>\n          {ripples.map((ripple) =>\n            reducedMotion ? (\n              <motion.span\n                key={ripple.id}\n                className=\"absolute rounded-full\"\n                style={{\n                  left: ripple.x,\n                  top: ripple.y,\n                  translateX: \"-50%\",\n                  translateY: \"-50%\",\n                  width: ripple.size * 0.4,\n                  height: ripple.size * 0.4,\n                  background: `color-mix(in oklab, ${color} 40%, transparent)`,\n                }}\n                initial={{ opacity: 0.5 }}\n                animate={{ opacity: 0 }}\n                transition={{ duration: 0.4 }}\n              />\n            ) : (\n              <React.Fragment key={ripple.id}>\n                {/* Shockwave ring */}\n                <motion.span\n                  className=\"absolute rounded-full\"\n                  style={{\n                    left: ripple.x,\n                    top: ripple.y,\n                    translateX: \"-50%\",\n                    translateY: \"-50%\",\n                    border: `2.5px solid color-mix(in oklab, ${color} 85%, white)`,\n                    boxShadow: `0 0 10px 1px color-mix(in oklab, ${color} 55%, transparent), inset 0 0 6px 0 color-mix(in oklab, ${color} 40%, transparent)`,\n                  }}\n                  initial={{ width: 0, height: 0, opacity: 1 }}\n                  animate={{ width: ripple.size, height: ripple.size, opacity: 0 }}\n                  transition={{ duration: duration * 1.15, ease: [0.15, 0.65, 0.3, 1] }}\n                />\n                {/* Cooled interior: a darker, briefly desaturated core */}\n                <motion.span\n                  className=\"absolute rounded-full\"\n                  style={{\n                    left: ripple.x,\n                    top: ripple.y,\n                    translateX: \"-50%\",\n                    translateY: \"-50%\",\n                    background:\n                      \"radial-gradient(circle, rgba(10,10,12,0.55) 0%, rgba(10,10,12,0) 70%)\",\n                  }}\n                  initial={{ width: 0, height: 0, opacity: 1 }}\n                  animate={{ width: ripple.size * 0.75, height: ripple.size * 0.75, opacity: 0 }}\n                  transition={{ duration: duration * 0.8, ease: \"easeOut\" }}\n                />\n                {/* Spark particles */}\n                {sparks &&\n                  [0, 1, 2].map((i) => {\n                    const angle = ripple.keyboard\n                      ? (i / 3) * Math.PI * 2 + Math.PI / 6\n                      : (i / 3) * Math.PI * 2 + (ripple.id % 7);\n                    const dist = ripple.size * 0.22;\n                    return (\n                      <motion.span\n                        key={i}\n                        className=\"absolute h-[3px] w-[3px] rounded-full\"\n                        style={{\n                          left: ripple.x,\n                          top: ripple.y,\n                          background: color,\n                          boxShadow: `0 0 4px 1px ${color}`,\n                        }}\n                        initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n                        animate={{\n                          x: Math.cos(angle) * dist,\n                          y: Math.sin(angle) * dist,\n                          opacity: 0,\n                          scale: 0.3,\n                        }}\n                        transition={{ duration: duration * 0.65, ease: \"easeOut\" }}\n                      />\n                    );\n                  })}\n              </React.Fragment>\n            )\n          )}\n        </AnimatePresence>\n      </span>\n\n      <span className=\"relative z-10\">{children}</span>\n    </motion.button>\n  );\n}\n"
    }
  ]
}