{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ash-burst-button",
  "type": "registry:ui",
  "title": "AshBurstButton",
  "description": "A button that burns on click and erupts ash that collides with the button via Matter.js physics, piling onto its surface.",
  "files": [
    {
      "path": "components/evil-buttons/ash-burst-button.tsx",
      "type": "registry:ui",
      "target": "components/evil-buttons/ash-burst-button.tsx",
      "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport { createPortal } from \"react-dom\";\r\nimport {\r\n  Bodies,\r\n  Body,\r\n  Composite,\r\n  Engine,\r\n  World,\r\n} from \"matter-js\";\r\nimport { motion, useAnimationControls, useReducedMotion } from \"motion/react\";\r\nimport { Button } from \"@/components/ui/button\";\r\n\r\n/** Void ash, dried blood, hellfire, bone. */\r\nconst ASH_COLORS = [\r\n  \"#0a0a0a\",\r\n  \"#1a0505\",\r\n  \"#3f0a0a\",\r\n  \"#7f1d1d\",\r\n  \"#991b1b\",\r\n  \"#b91c1c\",\r\n  \"#dc2626\",\r\n  \"#ea580c\",\r\n  \"#f97316\",\r\n  \"#fef3c7\",\r\n];\r\n\r\ntype ParticleKind = \"circle\" | \"square\" | \"shard\" | \"skull\";\r\n\r\ntype ParticleMeta = {\r\n  color: string;\r\n  kind: ParticleKind;\r\n  size: number;\r\n  born: number;\r\n  life: number;\r\n};\r\n\r\ntype AshSim = {\r\n  engine: Matter.Engine;\r\n  buttonBody: Matter.Body;\r\n  leftLip: Matter.Body;\r\n  rightLip: Matter.Body;\r\n  meta: Map<number, ParticleMeta>;\r\n  raf: number;\r\n  canvas: HTMLCanvasElement;\r\n  ctx: CanvasRenderingContext2D;\r\n  startedAt: number;\r\n  lastW: number;\r\n  lastH: number;\r\n};\r\n\r\nexport interface AshBurstButtonProps\r\n  extends Omit<React.ComponentProps<typeof Button>, \"onClick\"> {\r\n  /** Button label. Falls back to `label` when no children are provided. */\r\n  children?: React.ReactNode;\r\n  /** Label used when no children are provided. */\r\n  label?: React.ReactNode;\r\n  /** Ash particles per burst. */\r\n  particleCount?: number;\r\n  /** Burst spread in degrees. */\r\n  spread?: number;\r\n  /** Extra launch velocity (mapped into Matter velocity). */\r\n  startVelocity?: number;\r\n  /** Custom ash / ember colors. */\r\n  colors?: string[];\r\n  /** Fired after each ash burst. */\r\n  onDestroy?: () => void;\r\n}\r\n\r\nfunction pick<T>(items: T[]): T {\r\n  return items[Math.floor(Math.random() * items.length)]!;\r\n}\r\n\r\nfunction drawShard(\r\n  ctx: CanvasRenderingContext2D,\r\n  size: number,\r\n  color: string,\r\n) {\r\n  ctx.fillStyle = color;\r\n  ctx.beginPath();\r\n  ctx.moveTo(0, -size);\r\n  ctx.lineTo(size * 0.55, -size * 0.15);\r\n  ctx.lineTo(size * 0.35, size);\r\n  ctx.lineTo(-size * 0.45, size * 0.55);\r\n  ctx.lineTo(-size * 0.2, -size * 0.35);\r\n  ctx.closePath();\r\n  ctx.fill();\r\n}\r\n\r\nfunction sizeCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {\r\n  const dpr = Math.min(window.devicePixelRatio || 1, 2);\r\n  const w = window.innerWidth;\r\n  const h = window.innerHeight;\r\n  canvas.width = w * dpr;\r\n  canvas.height = h * dpr;\r\n  canvas.style.width = `${w}px`;\r\n  canvas.style.height = `${h}px`;\r\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\r\n}\r\n\r\nfunction syncColliders(sim: AshSim, button: HTMLElement) {\r\n  const rect = button.getBoundingClientRect();\r\n  const cx = rect.left + rect.width / 2;\r\n  const cy = rect.top + rect.height / 2;\r\n  const w = Math.max(8, rect.width);\r\n  const h = Math.max(8, rect.height);\r\n\r\n  if (sim.lastW > 0 && sim.lastH > 0) {\r\n    const sx = w / sim.lastW;\r\n    const sy = h / sim.lastH;\r\n    if (Math.abs(sx - 1) > 0.005 || Math.abs(sy - 1) > 0.005) {\r\n      Body.scale(sim.buttonBody, sx, sy);\r\n    }\r\n  }\r\n\r\n  Body.setPosition(sim.buttonBody, { x: cx, y: cy });\r\n\r\n  const lipW = Math.max(10, w * 0.1);\r\n  const lipH = 7;\r\n  Body.setPosition(sim.leftLip, {\r\n    x: rect.left + lipW / 2,\r\n    y: rect.top - lipH / 2 + 1,\r\n  });\r\n  Body.setPosition(sim.rightLip, {\r\n    x: rect.right - lipW / 2,\r\n    y: rect.top - lipH / 2 + 1,\r\n  });\r\n\r\n  sim.lastW = w;\r\n  sim.lastH = h;\r\n}\r\n\r\nfunction createAshSimulation(\r\n  canvas: HTMLCanvasElement,\r\n  button: HTMLElement,\r\n  options: {\r\n    particleCount: number;\r\n    spread: number;\r\n    startVelocity: number;\r\n    colors: string[];\r\n  },\r\n): AshSim {\r\n  const ctx = canvas.getContext(\"2d\")!;\r\n  sizeCanvas(canvas, ctx);\r\n\r\n  const engine = Engine.create({\r\n    gravity: { x: 0, y: 1.4 },\r\n  });\r\n\r\n  const rect = button.getBoundingClientRect();\r\n  const cx = rect.left + rect.width / 2;\r\n  const cy = rect.top + rect.height / 2;\r\n  const w = Math.max(8, rect.width);\r\n  const h = Math.max(8, rect.height);\r\n\r\n  const buttonBody = Bodies.rectangle(cx, cy, w, h, {\r\n    isStatic: true,\r\n    friction: 1.25,\r\n    frictionStatic: 1.4,\r\n    restitution: 0.18,\r\n    chamfer: { radius: Math.min(8, h / 2) },\r\n    label: \"ash-button\",\r\n  });\r\n\r\n  const lipW = Math.max(10, w * 0.1);\r\n  const lipH = 7;\r\n  const leftLip = Bodies.rectangle(\r\n    rect.left + lipW / 2,\r\n    rect.top - lipH / 2 + 1,\r\n    lipW,\r\n    lipH,\r\n    {\r\n      isStatic: true,\r\n      friction: 1.4,\r\n      restitution: 0.04,\r\n      label: \"ash-lip\",\r\n    },\r\n  );\r\n  const rightLip = Bodies.rectangle(\r\n    rect.right - lipW / 2,\r\n    rect.top - lipH / 2 + 1,\r\n    lipW,\r\n    lipH,\r\n    {\r\n      isStatic: true,\r\n      friction: 1.4,\r\n      restitution: 0.04,\r\n      label: \"ash-lip\",\r\n    },\r\n  );\r\n\r\n  World.add(engine.world, [buttonBody, leftLip, rightLip]);\r\n\r\n  const meta = new Map<number, ParticleMeta>();\r\n  const now = performance.now();\r\n  const count = Math.max(12, options.particleCount);\r\n\r\n  for (let i = 0; i < count; i++) {\r\n    const kindRoll = Math.random();\r\n    const kind: ParticleKind =\r\n      kindRoll > 0.93\r\n        ? \"skull\"\r\n        : kindRoll > 0.72\r\n          ? \"shard\"\r\n          : kindRoll > 0.4\r\n            ? \"square\"\r\n            : \"circle\";\r\n\r\n    const size =\r\n      kind === \"skull\"\r\n        ? 10 + Math.random() * 6\r\n        : kind === \"shard\"\r\n          ? 3.5 + Math.random() * 4\r\n          : 2.2 + Math.random() * 3.8;\r\n\r\n    const halfSpread = (options.spread * Math.PI) / 180 / 2;\r\n    const angle = -Math.PI / 2 + (Math.random() * 2 - 1) * halfSpread;\r\n    // Balanced kick: readable burst without flying off too hard.\r\n    const speed =\r\n      options.startVelocity * (0.45 + Math.random() * 0.55) * 0.7;\r\n\r\n    // Mostly explode from center; a few rain down later to settle on top.\r\n    const rain = Math.random() > 0.72;\r\n    const spawnX = rain\r\n      ? rect.left + Math.random() * w\r\n      : cx + (Math.random() - 0.5) * w * 0.4;\r\n    const spawnY = rain\r\n      ? rect.top - 8 - Math.random() * 28\r\n      : cy + (Math.random() - 0.5) * h * 0.3;\r\n\r\n    const body =\r\n      kind === \"circle\" || kind === \"skull\"\r\n        ? Bodies.circle(spawnX, spawnY, size * (kind === \"skull\" ? 0.55 : 0.85), {\r\n            restitution: 0.35 + Math.random() * 0.3,\r\n            friction: 0.65 + Math.random() * 0.4,\r\n            frictionAir: 0.008 + Math.random() * 0.012,\r\n            density: kind === \"skull\" ? 0.0018 : 0.0012,\r\n            label: \"ash-particle\",\r\n          })\r\n        : Bodies.rectangle(\r\n            spawnX,\r\n            spawnY,\r\n            size * (kind === \"shard\" ? 1.4 : 1.6),\r\n            size * (kind === \"shard\" ? 2.2 : 1.6),\r\n            {\r\n              restitution: 0.3 + Math.random() * 0.28,\r\n              friction: 0.7 + Math.random() * 0.4,\r\n              frictionAir: 0.009 + Math.random() * 0.012,\r\n              density: 0.0013,\r\n              angle: Math.random() * Math.PI,\r\n              label: \"ash-particle\",\r\n            },\r\n          );\r\n\r\n    if (rain) {\r\n      Body.setVelocity(body, {\r\n        x: (Math.random() - 0.5) * 3.5,\r\n        y: 1 + Math.random() * 3,\r\n      });\r\n    } else {\r\n      Body.setVelocity(body, {\r\n        x: Math.cos(angle) * speed + (Math.random() - 0.5) * 4,\r\n        y: Math.sin(angle) * speed - (2 + Math.random() * 5),\r\n      });\r\n    }\r\n    Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.55);\r\n\r\n    meta.set(body.id, {\r\n      color: pick(options.colors),\r\n      kind,\r\n      size,\r\n      born: now,\r\n      life: 3200 + Math.random() * 2200,\r\n    });\r\n\r\n    World.add(engine.world, body);\r\n  }\r\n\r\n  return {\r\n    engine,\r\n    buttonBody,\r\n    leftLip,\r\n    rightLip,\r\n    meta,\r\n    raf: 0,\r\n    canvas,\r\n    ctx,\r\n    startedAt: now,\r\n    lastW: w,\r\n    lastH: h,\r\n  };\r\n}\r\n\r\nfunction paintSim(sim: AshSim, button: HTMLElement) {\r\n  const { ctx, canvas, engine, meta } = sim;\r\n  const w = canvas.clientWidth;\r\n  const h = canvas.clientHeight;\r\n\r\n  syncColliders(sim, button);\r\n  Engine.update(engine, 1000 / 60);\r\n\r\n  ctx.clearRect(0, 0, w, h);\r\n\r\n  const now = performance.now();\r\n  const toRemove: Matter.Body[] = [];\r\n\r\n  for (const body of Composite.allBodies(engine.world)) {\r\n    if (body.label !== \"ash-particle\") continue;\r\n    const info = meta.get(body.id);\r\n    if (!info) continue;\r\n\r\n    const age = now - info.born;\r\n    const fade = Math.max(0, 1 - age / info.life);\r\n    if (\r\n      fade <= 0 ||\r\n      body.position.y > h + 48 ||\r\n      body.position.x < -48 ||\r\n      body.position.x > w + 48\r\n    ) {\r\n      toRemove.push(body);\r\n      continue;\r\n    }\r\n\r\n    ctx.save();\r\n    ctx.translate(body.position.x, body.position.y);\r\n    ctx.rotate(body.angle);\r\n    ctx.globalAlpha = 0.4 + fade * 0.6;\r\n\r\n    if (info.kind === \"skull\") {\r\n      ctx.font = `${info.size * 1.8}px serif`;\r\n      ctx.textAlign = \"center\";\r\n      ctx.textBaseline = \"middle\";\r\n      ctx.fillText(\"💀\", 0, 0);\r\n    } else if (info.kind === \"shard\") {\r\n      drawShard(ctx, info.size, info.color);\r\n    } else if (info.kind === \"square\") {\r\n      ctx.fillStyle = info.color;\r\n      ctx.fillRect(-info.size, -info.size, info.size * 2, info.size * 2);\r\n    } else {\r\n      ctx.fillStyle = info.color;\r\n      ctx.beginPath();\r\n      ctx.arc(0, 0, info.size, 0, Math.PI * 2);\r\n      ctx.fill();\r\n    }\r\n\r\n    if (info.color.startsWith(\"#f\") || info.color.startsWith(\"#e\")) {\r\n      ctx.globalAlpha = fade * 0.3;\r\n      ctx.fillStyle = \"#f97316\";\r\n      ctx.beginPath();\r\n      ctx.arc(0, 0, info.size * 1.7, 0, Math.PI * 2);\r\n      ctx.fill();\r\n    }\r\n\r\n    ctx.restore();\r\n  }\r\n\r\n  for (const body of toRemove) {\r\n    meta.delete(body.id);\r\n    World.remove(engine.world, body);\r\n  }\r\n\r\n  return meta.size > 0 && now - sim.startedAt < 6500;\r\n}\r\n\r\nexport const AshBurstButton = React.forwardRef<\r\n  HTMLButtonElement,\r\n  AshBurstButtonProps\r\n>(\r\n  (\r\n    {\r\n      children,\r\n      label = \"Destroy\",\r\n      particleCount = 96,\r\n      spread = 120,\r\n      startVelocity = 48,\r\n      colors = ASH_COLORS,\r\n      onDestroy,\r\n      className,\r\n      disabled,\r\n      variant = \"destructive\",\r\n      size,\r\n      type = \"button\",\r\n      ...props\r\n    },\r\n    ref,\r\n  ) => {\r\n    const buttonRef = React.useRef<HTMLButtonElement | null>(null);\r\n    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\r\n    const simRef = React.useRef<AshSim | null>(null);\r\n    const burnControls = useAnimationControls();\r\n    const preferReducedMotion = useReducedMotion();\r\n    const [simActive, setSimActive] = React.useState(false);\r\n\r\n    const setButtonRef = (node: HTMLButtonElement | null) => {\r\n      buttonRef.current = node;\r\n      if (typeof ref === \"function\") ref(node);\r\n      else if (ref) ref.current = node;\r\n    };\r\n\r\n    const stopSim = React.useCallback(() => {\r\n      const sim = simRef.current;\r\n      if (!sim) {\r\n        setSimActive(false);\r\n        return;\r\n      }\r\n      cancelAnimationFrame(sim.raf);\r\n      World.clear(sim.engine.world, false);\r\n      Engine.clear(sim.engine);\r\n      sim.ctx.clearRect(0, 0, sim.canvas.clientWidth, sim.canvas.clientHeight);\r\n      simRef.current = null;\r\n      setSimActive(false);\r\n    }, []);\r\n\r\n    React.useEffect(() => () => stopSim(), [stopSim]);\r\n\r\n    const startPhysicsBurst = React.useCallback(() => {\r\n      const button = buttonRef.current;\r\n      const canvas = canvasRef.current;\r\n      if (!button || !canvas) return;\r\n\r\n      const existing = simRef.current;\r\n      if (existing) {\r\n        cancelAnimationFrame(existing.raf);\r\n        World.clear(existing.engine.world, false);\r\n        Engine.clear(existing.engine);\r\n        existing.ctx.clearRect(\r\n          0,\r\n          0,\r\n          existing.canvas.clientWidth,\r\n          existing.canvas.clientHeight,\r\n        );\r\n        simRef.current = null;\r\n      }\r\n\r\n      setSimActive(true);\r\n\r\n      const sim = createAshSimulation(canvas, button, {\r\n        particleCount,\r\n        spread,\r\n        startVelocity,\r\n        colors,\r\n      });\r\n      simRef.current = sim;\r\n\r\n      const tick = () => {\r\n        const current = simRef.current;\r\n        const btn = buttonRef.current;\r\n        if (!current || !btn) return;\r\n\r\n        const keepGoing = paintSim(current, btn);\r\n        if (keepGoing) {\r\n          current.raf = requestAnimationFrame(tick);\r\n        } else {\r\n          stopSim();\r\n        }\r\n      };\r\n\r\n      sim.raf = requestAnimationFrame(tick);\r\n    }, [colors, particleCount, spread, startVelocity, stopSim]);\r\n\r\n    const handleClick = () => {\r\n      if (disabled || !buttonRef.current) return;\r\n\r\n      if (!preferReducedMotion) {\r\n        startPhysicsBurst();\r\n      }\r\n      onDestroy?.();\r\n\r\n      if (preferReducedMotion) return;\r\n\r\n      void burnControls\r\n        .start({\r\n          scale: 0.78,\r\n          opacity: 0.35,\r\n          filter:\r\n            \"brightness(0.25) contrast(1.6) saturate(2.4) hue-rotate(-12deg)\",\r\n          transition: { duration: 0.1, ease: \"easeIn\" },\r\n        })\r\n        .then(() =>\r\n          burnControls.start({\r\n            scale: 1.12,\r\n            opacity: 1,\r\n            filter: \"brightness(1.45) contrast(1.35) saturate(1.8)\",\r\n            transition: {\r\n              type: \"spring\",\r\n              stiffness: 560,\r\n              damping: 12,\r\n              mass: 0.45,\r\n            },\r\n          }),\r\n        )\r\n        .then(() =>\r\n          burnControls.start({\r\n            scale: 1,\r\n            opacity: 1,\r\n            filter: \"brightness(1) contrast(1) saturate(1) hue-rotate(0deg)\",\r\n            transition: {\r\n              type: \"spring\",\r\n              stiffness: 360,\r\n              damping: 20,\r\n              mass: 0.55,\r\n            },\r\n          }),\r\n        );\r\n    };\r\n\r\n    const displayLabel = children ?? label;\r\n    const [mounted, setMounted] = React.useState(false);\r\n\r\n    React.useEffect(() => {\r\n      setMounted(true);\r\n    }, []);\r\n\r\n    const overlay =\r\n      mounted && typeof document !== \"undefined\"\r\n        ? createPortal(\r\n            <canvas\r\n              ref={canvasRef}\r\n              aria-hidden\r\n              className=\"pointer-events-none fixed inset-0 z-[80]\"\r\n              style={{ opacity: simActive ? 1 : 0 }}\r\n            />,\r\n            document.body,\r\n          )\r\n        : null;\r\n\r\n    return (\r\n      <>\r\n        {overlay}\r\n        <motion.span\r\n          className=\"relative inline-flex\"\r\n          initial={{\r\n            scale: 1,\r\n            opacity: 1,\r\n            filter: \"brightness(1) contrast(1) saturate(1) hue-rotate(0deg)\",\r\n          }}\r\n          animate={burnControls}\r\n        >\r\n          <Button\r\n            ref={setButtonRef}\r\n            type={type}\r\n            variant={variant}\r\n            size={size}\r\n            disabled={disabled}\r\n            onClick={handleClick}\r\n            className={className}\r\n            {...props}\r\n          >\r\n            {displayLabel}\r\n          </Button>\r\n        </motion.span>\r\n      </>\r\n    );\r\n  },\r\n);\r\n\r\nAshBurstButton.displayName = \"AshBurstButton\";\r\n\r\nexport default AshBurstButton;\r\n"
    }
  ],
  "registryDependencies": [
    "button"
  ],
  "dependencies": [
    "matter-js",
    "clsx",
    "tailwind-merge",
    "motion"
  ]
}
