Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions components/evil-buttons/confetti-button.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof Button>, "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<HTMLButtonElement | null>(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 (
<motion.span
className="inline-flex"
initial={{ scale: 1 }}
animate={popControls}
>
<Button
ref={setButtonRef}
type={type}
variant={variant}
size={size}
disabled={disabled}
onClick={handleClick}
className={className}
{...props}
>
{displayLabel}
</Button>
</motion.span>
);
},
);

ConfettiButton.displayName = "ConfettiButton";

export default ConfettiButton;
7 changes: 7 additions & 0 deletions components/landing/landing-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -245,6 +246,12 @@ const showcase: ButtonShowcase[] = [
registryName: "hold-confirm-button",
render: () => <HoldConfirmButton />,
},
{
name: "ConfettiButton",
href: "/docs/confetti-button",
registryName: "confetti-button",
render: () => <ConfettiButton>Celebrate</ConfettiButton>,
},
];

function ButtonCell({ item }: { item: ButtonShowcase }) {
Expand Down
2 changes: 2 additions & 0 deletions components/mdx-custom-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -129,5 +130,6 @@ export function getCustomMDXComponents(): MDXComponents {
CooldownButton,
PillButton,
HoldConfirmButton,
ConfettiButton,
};
}
62 changes: 62 additions & 0 deletions content/docs/confetti-button.mdx
Original file line number Diff line number Diff line change
@@ -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

<PreviewCard title="ConfettiButton" note="Click for confetti">
<ConfettiButton />
</PreviewCard>

## Install

Add the item with the shadcn CLI.

<Cmd>@evilbuttons/confetti-button</Cmd>

## Usage

```tsx
import { ConfettiButton } from "@/components/evil-buttons/confetti-button";

export function ButtonDemo() {
return (
<ConfettiButton
colors={["#ff577f", "#ff884b", "#ffd384", "#fff9b0"]}
onCelebrate={() => console.log("Celebrated!")}
>
Celebrate
</ConfettiButton>
);
}
```

## 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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -35,6 +36,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions public/r/confetti-button.json
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof Button>, \"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<HTMLButtonElement | null>(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 <motion.span\n className=\"inline-flex\"\n initial={{ scale: 1 }}\n animate={popControls}\n >\n <Button\n ref={setButtonRef}\n type={type}\n variant={variant}\n size={size}\n disabled={disabled}\n onClick={handleClick}\n className={className}\n {...props}\n >\n {displayLabel}\n </Button>\n </motion.span>\n );\n },\n);\n\nConfettiButton.displayName = \"ConfettiButton\";\n\nexport default ConfettiButton;"
}
],
"registryDependencies": [
"button"
],
"dependencies": [
"canvas-confetti",
"clsx",
"tailwind-merge",
"motion"
]
}
9 changes: 9 additions & 0 deletions public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
10 changes: 10 additions & 0 deletions registry.components.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
Loading