{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "demonic-button",
  "type": "registry:ui",
  "title": "DemonicButton",
  "description": "A hold-to-summon button: keep pressing to raise ridged devil horns from behind it while a canvas fire builds into a roar.",
  "files": [
    {
      "path": "components/evil-buttons/demonic-button.tsx",
      "type": "registry:ui",
      "target": "components/evil-buttons/demonic-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DemonicButtonProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    \"children\" | \"onClick\"\n  > {\n  /** Button label. Falls back to `label`. */\n  children?: React.ReactNode;\n  /** Label used when no children are provided. */\n  label?: React.ReactNode;\n  /** Milliseconds of holding needed to fully summon the demon. */\n  holdDuration?: number;\n  /** Fired once each time the charge reaches 100%. */\n  onSummon?: () => void;\n}\n\n/** Extra canvas room around the button so flames can spill past its edges. */\nconst FIRE_BLEED_X = 40;\nconst FIRE_HEIGHT = 150;\n/** Charge drains this many times faster than it fills once released. */\nconst DRAIN_RATE = 2.4;\n\ntype Particle = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  age: number;\n  life: number;\n  size: number;\n  seed: number;\n};\n\n// Blackbody-ish ramp: white-hot core, through orange and red, into smoke.\nconst FIRE_RAMP: [number, [number, number, number, number]][] = [\n  [0, [255, 244, 214, 1]],\n  [0.18, [255, 196, 92, 0.95]],\n  [0.42, [255, 118, 38, 0.8]],\n  [0.68, [206, 48, 24, 0.5]],\n  [0.86, [96, 24, 18, 0.22]],\n  [1, [40, 20, 20, 0]],\n];\n\nconst SPRITE_STEPS = 24;\nconst SPRITE_SIZE = 64;\n\nfunction sampleRamp(t: number) {\n  for (let i = 1; i < FIRE_RAMP.length; i++) {\n    const [t1, c1] = FIRE_RAMP[i];\n    if (t <= t1) {\n      const [t0, c0] = FIRE_RAMP[i - 1];\n      const k = (t - t0) / (t1 - t0);\n      return c0.map((v, j) => v + (c1[j] - v) * k) as [\n        number,\n        number,\n        number,\n        number,\n      ];\n    }\n  }\n  return FIRE_RAMP[FIRE_RAMP.length - 1][1];\n}\n\n/** Pre-rendered soft radial blobs, one per step of the fire ramp. */\nfunction createSprites() {\n  return Array.from({ length: SPRITE_STEPS }, (_, i) => {\n    const [r, g, b, a] = sampleRamp(i / (SPRITE_STEPS - 1));\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = canvas.height = SPRITE_SIZE;\n    const ctx = canvas.getContext(\"2d\");\n    if (ctx) {\n      const half = SPRITE_SIZE / 2;\n      const gradient = ctx.createRadialGradient(half, half, 0, half, half, half);\n      gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${a})`);\n      gradient.addColorStop(0.45, `rgba(${r}, ${g}, ${b}, ${a * 0.55})`);\n      gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);\n      ctx.fillStyle = gradient;\n      ctx.fillRect(0, 0, SPRITE_SIZE, SPRITE_SIZE);\n    }\n    return canvas;\n  });\n}\n\nfunction Horn({ side }: { side: \"left\" | \"right\" }) {\n  const id = React.useId().replace(/:/g, \"\");\n  // Outer edge sweeps out and up to the tip, inner edge curls back to the base.\n  const shape =\n    \"M13 64 C6 50 1.5 33 6.5 15 C8 9.5 10.5 5 13 2 C12.5 12 15 26 21 38 C24.5 45 28.5 53 31 64 Z\";\n\n  return (\n    <svg\n      viewBox=\"0 0 34 66\"\n      className={cn(\"h-full w-full\", side === \"right\" && \"-scale-x-100\")}\n      aria-hidden\n    >\n      <defs>\n        <linearGradient id={`${id}-body`} x1=\"0.5\" y1=\"1\" x2=\"0.2\" y2=\"0\">\n          <stop offset=\"0\" stopColor=\"#1c0907\" />\n          <stop offset=\"0.45\" stopColor=\"#4a120d\" />\n          <stop offset=\"0.8\" stopColor=\"#8a3a24\" />\n          <stop offset=\"1\" stopColor=\"#e9d6bf\" />\n        </linearGradient>\n        <linearGradient id={`${id}-shade`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          <stop offset=\"0\" stopColor=\"#ffffff\" stopOpacity=\"0.18\" />\n          <stop offset=\"0.35\" stopColor=\"#ffffff\" stopOpacity=\"0\" />\n          <stop offset=\"0.75\" stopColor=\"#000000\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#000000\" stopOpacity=\"0.45\" />\n        </linearGradient>\n        <clipPath id={`${id}-clip`}>\n          <path d={shape} />\n        </clipPath>\n      </defs>\n      <path d={shape} fill={`url(#${id}-body)`} />\n      <g clipPath={`url(#${id}-clip)`}>\n        <rect width=\"34\" height=\"66\" fill={`url(#${id}-shade)`} />\n        {/* Growth ridges wrapping around the horn. */}\n        <g\n          fill=\"none\"\n          stroke=\"#000000\"\n          strokeOpacity=\"0.38\"\n          strokeWidth=\"0.9\"\n          strokeLinecap=\"round\"\n        >\n          <path d=\"M3 56 Q17 50 32 58\" />\n          <path d=\"M2 48 Q15 42 28 49\" />\n          <path d=\"M2 40 Q13 35 25 41\" />\n          <path d=\"M2.5 32 Q12 28 21.5 33\" />\n          <path d=\"M3.5 25 Q11 21.5 18.5 25.5\" />\n          <path d=\"M5 18 Q10.5 15.5 16 18.5\" />\n          <path d=\"M7 12 Q10.5 10.5 14.5 12.5\" />\n        </g>\n        <path\n          d=\"M8 58 C4 44 3 30 7.5 16\"\n          fill=\"none\"\n          stroke=\"#ffd9b8\"\n          strokeOpacity=\"0.28\"\n          strokeWidth=\"1.2\"\n          strokeLinecap=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n\nexport const DemonicButton = React.forwardRef<\n  HTMLButtonElement,\n  DemonicButtonProps\n>(\n  (\n    {\n      children,\n      label = \"Summon\",\n      holdDuration = 2200,\n      onSummon,\n      className,\n      disabled,\n      type = \"button\",\n      ...props\n    },\n    ref,\n  ) => {\n    const wrapperRef = React.useRef<HTMLDivElement>(null);\n    const buttonRef = React.useRef<HTMLButtonElement | null>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement>(null);\n    const glowRef = React.useRef<HTMLSpanElement>(null);\n    const leftHornRef = React.useRef<HTMLSpanElement>(null);\n    const rightHornRef = React.useRef<HTMLSpanElement>(null);\n\n    const holdingRef = React.useRef(false);\n    const progressRef = React.useRef(0);\n    const summonedRef = React.useRef(false);\n    const particlesRef = React.useRef<Particle[]>([]);\n    const spawnDebtRef = React.useRef(0);\n    const spritesRef = React.useRef<HTMLCanvasElement[] | null>(null);\n    const rafRef = React.useRef<number | null>(null);\n    const lastTimeRef = React.useRef(0);\n    const sizeRef = React.useRef({ width: 0, height: 0, dpr: 1 });\n    const reduceMotionRef = React.useRef(false);\n    const onSummonRef = React.useRef(onSummon);\n    const [summoned, setSummoned] = React.useState(false);\n\n    React.useEffect(() => {\n      onSummonRef.current = onSummon;\n    }, [onSummon]);\n\n    const setButtonRef = (node: HTMLButtonElement | null) => {\n      buttonRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    };\n\n    // Keep the canvas backing store matched to its CSS size and DPR.\n    React.useEffect(() => {\n      const wrapper = wrapperRef.current;\n      const canvas = canvasRef.current;\n      if (!wrapper || !canvas) return;\n\n      const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      reduceMotionRef.current = query.matches;\n      const onMotionChange = () => {\n        reduceMotionRef.current = query.matches;\n      };\n      query.addEventListener(\"change\", onMotionChange);\n\n      const resize = () => {\n        const dpr = Math.min(window.devicePixelRatio || 1, 2);\n        const width = wrapper.offsetWidth + FIRE_BLEED_X * 2;\n        sizeRef.current = { width, height: FIRE_HEIGHT, dpr };\n        canvas.width = Math.round(width * dpr);\n        canvas.height = Math.round(FIRE_HEIGHT * dpr);\n      };\n      resize();\n      const observer = new ResizeObserver(resize);\n      observer.observe(wrapper);\n\n      return () => {\n        observer.disconnect();\n        query.removeEventListener(\"change\", onMotionChange);\n        if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n      };\n    }, []);\n\n    const paint = (progress: number, now: number) => {\n      const eased = 1 - Math.pow(1 - progress, 3);\n      // Slight overshoot as the horns break free, then settle.\n      const pop =\n        progress > 0.85 ? Math.sin(((progress - 0.85) / 0.15) * Math.PI) * 0.06 : 0;\n      const tremble =\n        reduceMotionRef.current || !holdingRef.current || progress >= 1\n          ? 0\n          : progress * progress;\n      const shakeX = (Math.sin(now * 0.09) + Math.sin(now * 0.137)) * 1.2 * tremble;\n      const shakeY = Math.cos(now * 0.113) * 0.8 * tremble;\n\n      const hornY = 26 * (1 - eased);\n      const hornScale = 0.55 + 0.45 * eased + pop;\n      const hornTilt = 18 * (1 - eased);\n      if (leftHornRef.current) {\n        leftHornRef.current.style.transform = `translateY(${hornY}px) rotate(${-hornTilt}deg) scale(${hornScale})`;\n        leftHornRef.current.style.opacity = String(Math.min(1, progress * 3));\n      }\n      if (rightHornRef.current) {\n        rightHornRef.current.style.transform = `translateY(${hornY}px) rotate(${hornTilt}deg) scale(${hornScale})`;\n        rightHornRef.current.style.opacity = String(Math.min(1, progress * 3));\n      }\n      if (buttonRef.current) {\n        buttonRef.current.style.transform = `translate(${shakeX}px, ${shakeY}px)`;\n      }\n      if (glowRef.current) {\n        const flicker = reduceMotionRef.current\n          ? 0\n          : (Math.sin(now * 0.021) + Math.sin(now * 0.047)) * 0.05;\n        glowRef.current.style.opacity = String(\n          Math.max(0, Math.min(1, eased * 0.9 + flicker * eased)),\n        );\n      }\n    };\n\n    const drawFire = (intensity: number, dt: number, now: number) => {\n      const canvas = canvasRef.current;\n      const ctx = canvas?.getContext(\"2d\");\n      const button = buttonRef.current;\n      if (!canvas || !ctx || !button) return;\n\n      spritesRef.current ??= createSprites();\n      const sprites = spritesRef.current;\n      const { width, height, dpr } = sizeRef.current;\n      const particles = particlesRef.current;\n      const baseY = height - button.offsetHeight / 2;\n      const left = FIRE_BLEED_X + 6;\n      const right = width - FIRE_BLEED_X - 6;\n      const center = width / 2;\n\n      if (intensity > 0.02 && !reduceMotionRef.current) {\n        spawnDebtRef.current += dt * (70 + 260 * intensity);\n        while (spawnDebtRef.current >= 1) {\n          spawnDebtRef.current -= 1;\n          // Bias spawns toward the middle so the flame has a body.\n          const spread = (Math.random() + Math.random()) / 2;\n          const life = 0.45 + Math.random() * 0.55 * (0.6 + intensity);\n          particles.push({\n            x: left + spread * (right - left),\n            y: baseY + Math.random() * 6,\n            vx: (Math.random() - 0.5) * 18,\n            vy: -(35 + Math.random() * 55) * (0.55 + intensity),\n            age: 0,\n            life,\n            size: (9 + Math.random() * 13) * (0.55 + 0.6 * intensity),\n            seed: Math.random() * 1000,\n          });\n        }\n      }\n\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n\n      let alive = 0;\n      for (let i = 0; i < particles.length; i++) {\n        const p = particles[i];\n        p.age += dt;\n        if (p.age >= p.life) continue;\n\n        // Buoyancy, a gentle pull to the centerline, and turbulent sway.\n        p.vy -= 60 * dt;\n        p.vx += (center - p.x) * 0.9 * dt;\n        p.vx += Math.sin(now * 0.004 + p.seed) * 40 * dt;\n        p.x += p.vx * dt;\n        p.y += p.vy * dt;\n        particles[alive++] = p;\n\n        const t = p.age / p.life;\n        const radius = p.size * (1 - t * 0.55);\n        const sprite = sprites[Math.min(SPRITE_STEPS - 1, Math.floor(t * SPRITE_STEPS))];\n        ctx.globalAlpha = Math.min(1, (1 - t) * 1.4);\n        ctx.drawImage(sprite, p.x - radius, p.y - radius, radius * 2, radius * 2);\n      }\n      particles.length = alive;\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = (now: number) => {\n      const last = lastTimeRef.current || now;\n      const dt = Math.min((now - last) / 1000, 0.05);\n      lastTimeRef.current = now;\n\n      const holding = holdingRef.current;\n      const step = dt / (holdDuration / 1000);\n      let progress = progressRef.current + (holding ? step : -step * DRAIN_RATE);\n      progress = Math.max(0, Math.min(1, progress));\n      progressRef.current = progress;\n\n      if (progress >= 1 && !summonedRef.current) {\n        summonedRef.current = true;\n        setSummoned(true);\n        onSummonRef.current?.();\n      } else if (progress < 1 && summonedRef.current) {\n        summonedRef.current = false;\n        setSummoned(false);\n      }\n\n      // Flames build slowly, then roar once the demon is summoned.\n      const intensity = progress >= 1 ? 1 : Math.pow(progress, 1.6) * 0.85;\n      paint(progress, now);\n      drawFire(intensity, dt, now);\n\n      if (!holding && progress <= 0 && particlesRef.current.length === 0) {\n        rafRef.current = null;\n        lastTimeRef.current = 0;\n        return;\n      }\n      rafRef.current = requestAnimationFrame(loop);\n    };\n\n    const ensureLoop = () => {\n      if (rafRef.current === null) {\n        lastTimeRef.current = 0;\n        rafRef.current = requestAnimationFrame(loop);\n      }\n    };\n\n    const startHold = () => {\n      if (disabled) return;\n      holdingRef.current = true;\n      buttonRef.current?.setAttribute(\"data-holding\", \"\");\n      ensureLoop();\n    };\n\n    const stopHold = () => {\n      if (!holdingRef.current) return;\n      holdingRef.current = false;\n      buttonRef.current?.removeAttribute(\"data-holding\");\n      ensureLoop();\n    };\n\n    return (\n      <div ref={wrapperRef} className=\"relative inline-flex select-none\">\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"pointer-events-none absolute bottom-1/2 z-0\"\n          style={{\n            left: -FIRE_BLEED_X,\n            width: `calc(100% + ${FIRE_BLEED_X * 2}px)`,\n            height: FIRE_HEIGHT,\n          }}\n        />\n\n        <span\n          ref={leftHornRef}\n          aria-hidden\n          className=\"pointer-events-none absolute bottom-[45%] left-[14%] z-[1] h-11 w-6 origin-bottom opacity-0\"\n          style={{ transform: \"translateY(26px) rotate(-18deg) scale(0.55)\" }}\n        >\n          <Horn side=\"left\" />\n        </span>\n        <span\n          ref={rightHornRef}\n          aria-hidden\n          className=\"pointer-events-none absolute right-[14%] bottom-[45%] z-[1] h-11 w-6 origin-bottom opacity-0\"\n          style={{ transform: \"translateY(26px) rotate(18deg) scale(0.55)\" }}\n        >\n          <Horn side=\"right\" />\n        </span>\n\n        <button\n          ref={setButtonRef}\n          type={type}\n          disabled={disabled}\n          data-state={summoned ? \"summoned\" : \"idle\"}\n          aria-live=\"polite\"\n          onPointerDown={(e) => {\n            if (e.button !== 0 && e.pointerType === \"mouse\") return;\n            e.currentTarget.setPointerCapture?.(e.pointerId);\n            startHold();\n          }}\n          onPointerUp={stopHold}\n          onPointerCancel={stopHold}\n          onLostPointerCapture={stopHold}\n          onKeyDown={(e) => {\n            if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n              e.preventDefault();\n              startHold();\n            }\n          }}\n          onKeyUp={(e) => {\n            if (e.key === \" \" || e.key === \"Enter\") {\n              e.preventDefault();\n              stopHold();\n            }\n          }}\n          onBlur={stopHold}\n          onContextMenu={(e) => e.preventDefault()}\n          className={cn(\n            \"relative z-10 inline-flex h-9 min-w-36 cursor-pointer touch-none items-center justify-center overflow-hidden rounded-[10px] px-5 text-sm font-medium text-neutral-50 outline-none\",\n            // Graded dark surface: dark outer hairline, faint inner ring, top highlight, soft drop.\n            \"bg-linear-to-b from-[#353535] to-[#272727] shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.25),0_4px_12px_-4px_rgb(0_0_0/0.4)]\",\n            // The loop writes `transform` every frame for the tremble, so the press animates the separate `scale` property.\n            \"transition-[scale,filter,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.35,0.64,1)] hover:brightness-110\",\n            // Press eases in fast and settles the shadow; release springs back on the slower base curve.\n            // data-holding covers pointer, touch, and Space/Enter holds, where :active is unreliable.\n            \"data-holding:scale-[0.97] data-holding:brightness-95 data-holding:duration-100 data-holding:ease-out data-holding:shadow-[0_0_0_1px_rgb(0_0_0/0.9),inset_0_0_0_1px_rgb(255_255_255/0.05),inset_0_1px_0_rgb(255_255_255/0.08),0_0_1px_rgb(0_0_0/0.2),0_1px_3px_-2px_rgb(0_0_0/0.3)] motion-reduce:data-holding:scale-100\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\",\n            className,\n          )}\n          {...props}\n        >\n          {/* Ember glow that rises from the bottom as the charge builds. */}\n          <span\n            ref={glowRef}\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 opacity-0\"\n            style={{\n              background:\n                \"radial-gradient(120% 90% at 50% 130%, rgb(255 140 50 / 0.55), rgb(200 40 20 / 0.28) 45%, transparent 75%)\",\n            }}\n          />\n          <span className=\"relative\">{children ?? label}</span>\n        </button>\n      </div>\n    );\n  },\n);\n\nDemonicButton.displayName = \"DemonicButton\";\n\nexport default DemonicButton;\n"
    }
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ]
}
