{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confetti-button",
  "type": "registry:ui",
  "title": "Confetti Button",
  "description": "A button that throws a handful of paper into the air on press, each piece lobbed on its own arc.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/confetti-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface ConfettiButtonProps extends React.ComponentProps<\"button\"> {\n  /** Pieces thrown by one press. */\n  count?: number\n  /** Milliseconds a piece stays in the air. */\n  duration?: number\n  /** Furthest a piece travels sideways, in pixels. */\n  spread?: number\n  /** Colours a piece is picked from. */\n  colors?: string[]\n}\n\ninterface Piece {\n  id: number\n  drift: number\n  rise: number\n  fall: number\n  spin: number\n  size: number\n  color: string\n  radius: string\n}\n\nconst DEFAULT_COLORS = [\"#2dd4bf\", \"#38bdf8\", \"#3b82f6\", \"#67e8f9\", \"#fbbf24\"]\n\n/**\n * Two nested spans per piece, because a lob is two motions on two clocks: the\n * outer drifts sideways at a constant rate while the inner rises and falls.\n * One element could only ever travel in a straight line.\n */\nexport function ConfettiButton({\n  children,\n  className,\n  count = 18,\n  duration = 900,\n  spread = 90,\n  colors = DEFAULT_COLORS,\n  onClick,\n  disabled,\n  style,\n  ...props\n}: ConfettiButtonProps) {\n  const [pieces, setPieces] = React.useState<Piece[]>([])\n  const nextId = React.useRef(0)\n  const timers = React.useRef<number[]>([])\n\n  React.useEffect(() => {\n    const pending = timers.current\n    return () => {\n      for (const timer of pending) {\n        window.clearTimeout(timer)\n      }\n    }\n  }, [])\n\n  const burst = React.useCallback(() => {\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      return\n    }\n\n    const palette = colors.length > 0 ? colors : DEFAULT_COLORS\n    const thrown: Piece[] = Array.from({ length: count }, () => {\n      // A fan across the top rather than a full circle. Pieces that start\n      // downward read as a leak, not a throw.\n      const angle = (-160 + Math.random() * 140) * (Math.PI / 180)\n      const reach = spread * (0.45 + Math.random() * 0.55)\n\n      return {\n        id: nextId.current++,\n        drift: Math.cos(angle) * reach,\n        rise: Math.sin(angle) * reach,\n        fall: Math.abs(Math.sin(angle) * reach) + spread * 0.85,\n        spin: Math.round(Math.random() * 720 - 360),\n        size: 5 + Math.round(Math.random() * 4),\n        color: palette[Math.floor(Math.random() * palette.length)],\n        // A mix of chips and dots, so a burst does not read as one shape\n        // repeated at different angles.\n        radius: Math.random() < 0.4 ? \"9999px\" : \"1px\",\n      }\n    })\n\n    setPieces((current) => [...current, ...thrown])\n\n    // One timer for the whole burst rather than one per piece, and the ids are\n    // monotonic, so a later burst is never cleared by an earlier timer.\n    const ids = new Set(thrown.map((piece) => piece.id))\n    const timer = window.setTimeout(() => {\n      setPieces((current) => current.filter((piece) => !ids.has(piece.id)))\n      timers.current = timers.current.filter((entry) => entry !== timer)\n    }, duration + 60)\n    timers.current.push(timer)\n  }, [colors, count, duration, spread])\n\n  return (\n    <button\n      data-slot=\"confetti-button\"\n      disabled={disabled}\n      onClick={(event) => {\n        onClick?.(event)\n        if (!disabled) {\n          burst()\n        }\n      }}\n      className={cn(\n        \"relative inline-flex items-center justify-center select-none\",\n        \"transition-transform duration-150 ease-[var(--ease-out-quart)] active:scale-[0.97] motion-reduce:transition-none\",\n        className\n      )}\n      style={style}\n      {...props}\n    >\n      {/* Pinned to the middle of the button and not clipped by it, so the\n          throw carries past the edge the way it should. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 left-1/2 z-10 size-0\"\n      >\n        {pieces.map((piece) => (\n          <span\n            key={piece.id}\n            className=\"animate-confetti-drift absolute block motion-reduce:hidden\"\n            style={\n              {\n                \"--confetti-duration\": `${duration}ms`,\n                \"--drift\": `${piece.drift}px`,\n              } as React.CSSProperties\n            }\n          >\n            <span\n              className=\"animate-confetti-fall block\"\n              style={\n                {\n                  width: piece.size,\n                  height: piece.size,\n                  background: piece.color,\n                  borderRadius: piece.radius,\n                  \"--confetti-duration\": `${duration}ms`,\n                  \"--rise\": `${piece.rise}px`,\n                  \"--fall\": `${piece.fall}px`,\n                  \"--spin\": `${piece.spin}deg`,\n                } as React.CSSProperties\n              }\n            />\n          </span>\n        ))}\n      </span>\n      {children}\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-confetti-drift": "confetti-drift var(--confetti-duration, 900ms) linear forwards",
      "animate-confetti-fall": "confetti-fall var(--confetti-duration, 900ms) forwards"
    }
  },
  "css": {
    "@keyframes confetti-drift": {
      "0%": {
        "transform": "translateX(0)",
        "opacity": "1"
      },
      "70%": {
        "opacity": "1"
      },
      "100%": {
        "transform": "translateX(var(--drift, 0px))",
        "opacity": "0"
      }
    },
    "@keyframes confetti-fall": {
      "0%": {
        "transform": "translateY(0) rotate(0deg)",
        "animation-timing-function": "cubic-bezier(0.165, 0.84, 0.44, 1)"
      },
      "32%": {
        "transform": "translateY(var(--rise, 0px)) rotate(calc(var(--spin, 0deg) * 0.3))",
        "animation-timing-function": "cubic-bezier(0.55, 0.06, 0.68, 0.19)"
      },
      "100%": {
        "transform": "translateY(var(--fall, 0px)) rotate(var(--spin, 0deg))"
      }
    }
  }
}