diff --git a/components/evil-buttons/confetti-button.tsx b/components/evil-buttons/confetti-button.tsx new file mode 100644 index 0000000..61699dd --- /dev/null +++ b/components/evil-buttons/confetti-button.tsx @@ -0,0 +1,141 @@ +"use client"; + +import * as React from "react"; +import confetti from "canvas-confetti"; +import { motion, useAnimationControls } from "motion/react"; +import { Button } from "@/components/ui/button"; + +export interface ConfettiButtonProps + extends Omit, "onClick"> { + /** Button label. Falls back to `label` when no children are provided. */ + children?: React.ReactNode; + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Confetti particles per burst. */ + particleCount?: number; + /** Confetti spread in degrees. */ + spread?: number; + /** Extra vertical launch velocity. */ + startVelocity?: number; + /** Custom confetti colors. */ + colors?: string[]; + /** Fired after each confetti burst. */ + onCelebrate?: () => void; +} + +function burstFromElement( + element: HTMLElement, + options: { + particleCount: number; + spread: number; + startVelocity: number; + colors?: string[]; + }, +) { + const rect = element.getBoundingClientRect(); + const x = (rect.left + rect.width / 2) / window.innerWidth; + const y = (rect.top + rect.height / 2) / window.innerHeight; + + void confetti({ + particleCount: options.particleCount, + spread: options.spread, + startVelocity: options.startVelocity, + origin: { x, y }, + colors: options.colors, + disableForReducedMotion: true, + }); +} + +export const ConfettiButton = React.forwardRef< + HTMLButtonElement, + ConfettiButtonProps +>( + ( + { + children, + label = "Continue", + particleCount = 120, + spread = 72, + startVelocity = 38, + colors, + onCelebrate, + className, + disabled, + variant, + size, + type = "button", + ...props + }, + ref, + ) => { + const buttonRef = React.useRef(null); + const popControls = useAnimationControls(); + + const setButtonRef = (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + const handleClick = () => { + if (disabled || !buttonRef.current) return; + + burstFromElement(buttonRef.current, { + particleCount, + spread, + startVelocity, + colors, + }); + onCelebrate?.(); + + void popControls + .start({ + scale: 1.12, + transition: { + type: "spring", + stiffness: 520, + damping: 14, + mass: 0.55, + }, + }) + .then(() => + popControls.start({ + scale: 1, + transition: { + type: "spring", + stiffness: 420, + damping: 20, + mass: 0.6, + }, + }), + ); + }; + + const displayLabel = children ?? label; + + return ( + + + + ); + }, +); + +ConfettiButton.displayName = "ConfettiButton"; + +export default ConfettiButton; \ No newline at end of file diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 539194d..156a96f 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -36,6 +36,7 @@ import TrollButton from "@/components/evil-buttons/troll-button"; import { DemonicButton } from "../evil-buttons/demonic-button"; import { PillButton } from "@/components/evil-buttons/pill-button"; import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; type ButtonShowcase = { name: string; @@ -245,6 +246,12 @@ const showcase: ButtonShowcase[] = [ registryName: "hold-confirm-button", render: () => , }, + { + name: "ConfettiButton", + href: "/docs/confetti-button", + registryName: "confetti-button", + render: () => Celebrate, + }, ]; function ButtonCell({ item }: { item: ButtonShowcase }) { diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index 7e106fe..7b0e471 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -30,6 +30,7 @@ import { MorphStatusButton } from "./evil-buttons/morph-status-button"; import { CooldownButton } from "./evil-buttons/cooldown-button"; import { PillButton } from "./evil-buttons/pill-button"; import { HoldConfirmButton } from "./evil-buttons/hold-confirm-button"; +import { ConfettiButton } from "./evil-buttons/confetti-button"; import { MorphStatusButtonDemo, MorphStatusButtonFailDemo, @@ -129,5 +130,6 @@ export function getCustomMDXComponents(): MDXComponents { CooldownButton, PillButton, HoldConfirmButton, + ConfettiButton, }; } diff --git a/content/docs/confetti-button.mdx b/content/docs/confetti-button.mdx new file mode 100644 index 0000000..ea25370 --- /dev/null +++ b/content/docs/confetti-button.mdx @@ -0,0 +1,62 @@ +--- +title: ConfettiButton +description: A plain shadcn button that pops on click and fires a confetti celebration burst. +--- + +ConfettiButton looks like any other default button. Click it and it pops with a spring bounce while confetti erupts from the button — a tiny celebration for doing almost nothing. + +## Preview + + + + + +## Install + +Add the item with the shadcn CLI. + +@evilbuttons/confetti-button + +## Usage + +```tsx +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; + +export function ButtonDemo() { + return ( + console.log("Celebrated!")} + > + Celebrate + + ); +} +``` + +## Props + +The component spreads shadcn `Button` props except `onClick`. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `children` | `React.ReactNode` | - | Button label. Falls back to `label`. | +| `label` | `React.ReactNode` | `"Continue"` | Label used when no children are provided. | +| `particleCount` | `number` | `120` | Confetti particles per burst. | +| `spread` | `number` | `72` | Confetti spread in degrees. | +| `startVelocity` | `number` | `38` | Extra vertical launch velocity. | +| `colors` | `string[]` | - | Custom confetti colors. | +| `onCelebrate` | `() => void` | - | Fired after each confetti burst. | +| `variant` | `Button` variant | `"default"` | Passed through to the shadcn button. | +| `size` | `Button` size | `"default"` | Passed through to the shadcn button. | +| `className` | `string` | - | Extra classes passed to the button. | + +## Notes + +- Built on the shadcn `Button` with default styling so it blends into any UI. +- Each click runs a spring pop animation and launches confetti from the button center via `canvas-confetti`. +- Confetti is skipped automatically when the user prefers reduced motion. + +## Registry + +The registry item includes `components/evil-buttons/confetti-button.tsx`, installs the shadcn `button` registry item, and adds `canvas-confetti`, `clsx`, `tailwind-merge`, and `motion` as dependencies. \ No newline at end of file diff --git a/package.json b/package.json index 8764a07..039d4b0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/mdx": "^2.0.13", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fumadocs-core": "^16.8.5", @@ -35,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ae480..a9e5a7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -69,6 +72,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.4 + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/node': specifier: ^20 version: 20.19.39 @@ -1648,6 +1654,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2098,6 +2107,9 @@ packages: caniuse-lite@1.0.30001791: resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6158,6 +6170,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/canvas-confetti@1.9.0': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6572,6 +6586,8 @@ snapshots: caniuse-lite@1.0.30001791: {} + canvas-confetti@1.9.4: {} + ccount@2.0.1: {} chalk@4.1.2: diff --git a/public/r/confetti-button.json b/public/r/confetti-button.json new file mode 100644 index 0000000..2d0f965 --- /dev/null +++ b/public/r/confetti-button.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "confetti-button", + "type": "registry:ui", + "title": "ConfettiButton", + "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", + "files": [ + { + "path": "components/evil-buttons/confetti-button.tsx", + "type": "registry:ui", + "target": "components/evil-buttons/confetti-button.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport confetti from \"canvas-confetti\";\nimport { motion, useAnimationControls } from \"motion/react\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface ConfettiButtonProps\n extends Omit, \"onClick\"> {\n /** Button label. Falls back to `label` when no children are provided. */\n children?: React.ReactNode;\n /** Label used when no children are provided. */\n label?: React.ReactNode;\n /** Confetti particles per burst. */\n particleCount?: number;\n /** Confetti spread in degrees. */\n spread?: number;\n /** Extra vertical launch velocity. */\n startVelocity?: number;\n /** Custom confetti colors. */\n colors?: string[];\n /** Fired after each confetti burst. */\n onCelebrate?: () => void;\n}\n\nfunction burstFromElement(\n element: HTMLElement,\n options: {\n particleCount: number;\n spread: number;\n startVelocity: number;\n colors?: string[];\n },\n) {\n const rect = element.getBoundingClientRect();\n const x = (rect.left + rect.width / 2) / window.innerWidth;\n const y = (rect.top + rect.height / 2) / window.innerHeight;\n\n void confetti({\n particleCount: options.particleCount,\n spread: options.spread,\n startVelocity: options.startVelocity,\n origin: { x, y },\n colors: options.colors,\n disableForReducedMotion: true,\n });\n}\n\nexport const ConfettiButton = React.forwardRef<\n HTMLButtonElement,\n ConfettiButtonProps\n>(\n (\n {\n children,\n label = \"Continue\",\n particleCount = 120,\n spread = 72,\n startVelocity = 38,\n colors,\n onCelebrate,\n className,\n disabled,\n variant,\n size,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const buttonRef = React.useRef(null);\n const popControls = useAnimationControls();\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 const handleClick = () => {\n if (disabled || !buttonRef.current) return;\n\n burstFromElement(buttonRef.current, {\n particleCount,\n spread,\n startVelocity,\n colors,\n });\n onCelebrate?.();\n\n void popControls\n .start({\n scale: 1.12,\n transition: {\n type: \"spring\",\n stiffness: 520,\n damping: 14,\n mass: 0.55,\n },\n })\n .then(() =>\n popControls.start({\n scale: 1,\n transition: {\n type: \"spring\",\n stiffness: 420,\n damping: 20,\n mass: 0.6,\n },\n }),\n );\n };\n\n const displayLabel = children ?? label;\n\n return (\n \n \n {displayLabel}\n \n \n );\n },\n);\n\nConfettiButton.displayName = \"ConfettiButton\";\n\nexport default ConfettiButton;" + } + ], + "registryDependencies": [ + "button" + ], + "dependencies": [ + "canvas-confetti", + "clsx", + "tailwind-merge", + "motion" + ] +} diff --git a/public/r/index.json b/public/r/index.json index 63c5786..a7847ae 100644 --- a/public/r/index.json +++ b/public/r/index.json @@ -254,6 +254,15 @@ "files": [ "components/evil-buttons/hold-confirm-button.tsx" ] + }, + { + "name": "confetti-button", + "type": "registry:ui", + "title": "ConfettiButton", + "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", + "files": [ + "components/evil-buttons/confetti-button.tsx" + ] } ] } diff --git a/registry.components.json b/registry.components.json index 8c35263..21583b2 100644 --- a/registry.components.json +++ b/registry.components.json @@ -260,5 +260,15 @@ "description": "A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect.", "file": "components/evil-buttons/hold-confirm-button.tsx", "dependencies": ["clsx", "tailwind-merge", "motion"] + }, + { + "name": "confetti-button", + "exportName": "ConfettiButton", + "docSlug": "confetti-button", + "title": "ConfettiButton", + "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", + "file": "components/evil-buttons/confetti-button.tsx", + "registryDependencies": ["button"], + "dependencies": ["canvas-confetti", "clsx", "tailwind-merge", "motion"] } ]