Buttons that disintegrate into particles — satisfying click feedback
Click button to disintegrate, then click restore
Copy URL or Code
Paste to your AI coding assistant and customize:
Done. Your AI handles the rest.
Fully customizable. Supports circles, triangles, and rectangles. Choose direction, speed, and colors. Perfect for delete buttons, submit confirmations, or any action that deserves a satisfying visual response.
/*
================================================================================
AI COMPONENT: ParticleButton
================================================================================
SETUP:
1. Create file: components/ParticleButton.tsx
2. Copy this entire code block into that file
3. Import: import ParticleButton from "@/components/ParticleButton"
4. No external dependencies required - pure React + Canvas
================================================================================
QUICK CUSTOMIZATION (via props)
================================================================================
| Prop | Type | Default |
|----------------------|----------------------------------------------|-----------|
| children | ReactNode | required |
| particleType | "circle" | "triangle" | "rectangle" | "circle" |
| particleStyle | "fill" | "stroke" | "fill" |
| direction | "left" | "right" | "top" | "bottom" | "left" |
| duration | number (ms) | 1000 |
| particleColor | string | () => string | button bg |
| particleSize | number | () => number | random 1-4|
| particleSpeed | number | () => number | random |
| particleAmount | number | 3 |
| oscillation | number | 20 |
| canvasPadding | number | 150 |
| onDisintegrate | () => void | undefined |
| onIntegrate | () => void | undefined |
| className | string | "" |
| buttonClassName | string | "" |
================================================================================
SOURCE CODE
================================================================================
*/
"use client";
import React, {
useRef,
useEffect,
useState,
useCallback,
ReactNode,
} from "react";
export type ParticleType = "circle" | "triangle" | "rectangle";
export type ParticleStyle = "fill" | "stroke";
export type Direction = "left" | "right" | "top" | "bottom";
export interface ParticleButtonProps {
children: ReactNode;
particleType?: ParticleType;
particleStyle?: ParticleStyle;
direction?: Direction;
duration?: number;
particleColor?: string | (() => string);
particleSize?: number | (() => number);
particleSpeed?: number | (() => number);
particleAmount?: number;
oscillation?: number;
canvasPadding?: number;
onDisintegrate?: () => void;
onIntegrate?: () => void;
className?: string;
buttonClassName?: string;
}
interface Particle {
startX: number;
startY: number;
x: number;
y: number;
color: string;
angle: number;
counter: number;
increase: number;
life: number;
death: number;
speed: number;
size: number;
}
export default function ParticleButton({
children,
particleType = "circle",
particleStyle = "fill",
direction = "left",
duration = 1000,
particleColor,
particleSize,
particleSpeed,
particleAmount = 3,
oscillation = 20,
canvasPadding = 150,
onDisintegrate,
onIntegrate,
className = "",
buttonClassName = "",
}: ParticleButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<Particle[]>([]);
const frameRef = useRef<number | null>(null);
const [isVisible, setIsVisible] = useState(true);
const [isAnimating, setIsAnimating] = useState(false);
const [buttonSize, setButtonSize] = useState<{ width: number; height: number } | null>(null);
const disintegratingRef = useRef(false);
const lastProgressRef = useRef(0);
const animationStartRef = useRef(0);
const rectRef = useRef<DOMRect | null>(null);
const isHorizontal = direction === "left" || direction === "right";
const getColor = useCallback(() => {
if (typeof particleColor === "function") return particleColor();
if (particleColor) return particleColor;
if (buttonRef.current) {
return getComputedStyle(buttonRef.current).backgroundColor;
}
return "#333333";
}, [particleColor]);
const getSize = useCallback(() => {
if (typeof particleSize === "function") return particleSize();
if (particleSize) return particleSize;
return Math.floor(Math.random() * 3) + 1;
}, [particleSize]);
const getSpeed = useCallback(() => {
if (typeof particleSpeed === "function") return particleSpeed();
if (particleSpeed) return particleSpeed;
return Math.random() * 4 - 2;
}, [particleSpeed]);
const addParticle = useCallback(
(x: number, y: number) => {
const frames = (duration * 60) / 1000;
const speed = getSpeed();
const color = getColor();
const disintegrating = disintegratingRef.current;
particlesRef.current.push({
startX: x,
startY: y,
x: disintegrating ? 0 : speed * -frames,
y: 0,
color,
angle: Math.random() * 360,
counter: disintegrating ? 0 : frames,
increase: (Math.PI * 2) / 100,
life: 0,
death: disintegrating ? frames - 20 + Math.random() * 40 : frames,
speed,
size: getSize(),
});
},
[duration, getColor, getSize, getSpeed]
);
const addParticles = useCallback(
(rect: DOMRect, progress: number) => {
const disintegrating = disintegratingRef.current;
const progressDiff = disintegrating
? progress - lastProgressRef.current
: lastProgressRef.current - progress;
lastProgressRef.current = progress;
let x = canvasPadding;
let y = canvasPadding;
const progressValue =
(isHorizontal ? rect.width : rect.height) * progress +
progressDiff * (disintegrating ? 100 : 220);
if (isHorizontal) {
x += direction === "left" ? progressValue : rect.width - progressValue;
} else {
y += direction === "top" ? progressValue : rect.height - progressValue;
}
let count = Math.floor(particleAmount * (progressDiff * 100 + 1));
if (count > 0) {
while (count--) {
addParticle(
x + (isHorizontal ? 0 : rect.width * Math.random()),
y + (isHorizontal ? rect.height * Math.random() : 0)
);
}
}
},
[addParticle, canvasPadding, direction, isHorizontal, particleAmount]
);
const renderParticles = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number) => {
ctx.clearRect(0, 0, width, height);
const disintegrating = disintegratingRef.current;
particlesRef.current.forEach((p) => {
if (p.life < p.death) {
ctx.save();
ctx.translate(p.startX, p.startY);
ctx.rotate((p.angle * Math.PI) / 180);
ctx.globalAlpha = disintegrating
? 1 - p.life / p.death
: p.life / p.death;
ctx.fillStyle = ctx.strokeStyle = p.color;
ctx.beginPath();
if (particleType === "circle") {
ctx.arc(p.x, p.y, p.size, 0, 2 * Math.PI);
} else if (particleType === "triangle") {
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x + p.size, p.y + p.size);
ctx.lineTo(p.x + p.size, p.y - p.size);
} else if (particleType === "rectangle") {
ctx.rect(p.x, p.y, p.size, p.size);
}
if (particleStyle === "fill") {
ctx.fill();
} else {
ctx.closePath();
ctx.stroke();
}
ctx.restore();
}
});
},
[particleStyle, particleType]
);
const updateParticles = useCallback(() => {
const disintegrating = disintegratingRef.current;
particlesRef.current = particlesRef.current.filter((p) => {
if (p.life > p.death) return false;
p.x += p.speed;
p.y = oscillation * Math.sin(p.counter * p.increase);
p.life++;
p.counter += disintegrating ? 1 : -1;
return true;
});
}, [oscillation]);
const easeInOutCubic = (t: number) => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
};
const loop = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx || !rectRef.current) return;
const elapsed = Date.now() - animationStartRef.current;
const rawProgress = Math.min(elapsed / duration, 1);
const progress = easeInOutCubic(rawProgress);
const disintegrating = disintegratingRef.current;
if (duration > 0) {
const animProgress = disintegrating ? progress : 1 - progress;
addParticles(rectRef.current, animProgress);
}
if (wrapperRef.current && buttonRef.current) {
const translateProp = isHorizontal ? "translateX" : "translateY";
const translateValue =
direction === "left" || direction === "top"
? progress * 100
: -progress * 100;
if (disintegrating) {
wrapperRef.current.style.transform = `${translateProp}(${translateValue}%)`;
buttonRef.current.style.transform = `${translateProp}(${-translateValue}%)`;
} else {
const inverseProgress = 1 - progress;
wrapperRef.current.style.transform = `${translateProp}(${translateValue * inverseProgress}%)`;
buttonRef.current.style.transform = `${translateProp}(${-translateValue * inverseProgress}%)`;
}
}
updateParticles();
renderParticles(ctx, canvas.width, canvas.height);
if (rawProgress < 1 || particlesRef.current.length > 0) {
frameRef.current = requestAnimationFrame(loop);
} else {
setIsAnimating(false);
if (canvas) canvas.style.display = "none";
if (disintegrating) {
setIsVisible(false);
onDisintegrate?.();
} else {
if (wrapperRef.current) wrapperRef.current.style.transform = "";
if (buttonRef.current) buttonRef.current.style.transform = "";
onIntegrate?.();
}
}
}, [addParticles, direction, duration, isHorizontal, onDisintegrate, onIntegrate, renderParticles, updateParticles]);
const disintegrate = useCallback(() => {
if (isAnimating || !isVisible) return;
const button = buttonRef.current;
const canvas = canvasRef.current;
if (!button || !canvas) return;
const rect = button.getBoundingClientRect();
setButtonSize({ width: rect.width, height: rect.height });
setIsAnimating(true);
disintegratingRef.current = true;
lastProgressRef.current = 0;
particlesRef.current = [];
rectRef.current = rect;
canvas.width = rect.width + canvasPadding * 2;
canvas.height = rect.height + canvasPadding * 2;
canvas.style.display = "block";
animationStartRef.current = Date.now();
frameRef.current = requestAnimationFrame(loop);
}, [canvasPadding, isAnimating, isVisible, loop]);
const integrate = useCallback(() => {
if (isAnimating || isVisible) return;
const button = buttonRef.current;
const canvas = canvasRef.current;
if (!button || !canvas) return;
setIsVisible(true);
setIsAnimating(true);
disintegratingRef.current = false;
lastProgressRef.current = 1;
particlesRef.current = [];
const rect = button.getBoundingClientRect();
rectRef.current = rect;
canvas.width = rect.width + canvasPadding * 2;
canvas.height = rect.height + canvasPadding * 2;
canvas.style.display = "block";
animationStartRef.current = Date.now();
frameRef.current = requestAnimationFrame(loop);
}, [canvasPadding, isAnimating, isVisible, loop]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
const containerStyle: React.CSSProperties = !isVisible && buttonSize
? { minWidth: buttonSize.width, minHeight: buttonSize.height }
: {};
return (
<div className={`particles-container relative inline-block ${className}`} style={containerStyle}>
<div
ref={wrapperRef}
className="particles-wrapper relative inline-block overflow-hidden"
style={{ visibility: isVisible ? "visible" : "hidden" }}
>
<button
ref={buttonRef}
onClick={disintegrate}
disabled={isAnimating}
className={`relative select-none ${buttonClassName}`}
>
{children}
</button>
</div>
<canvas
ref={canvasRef}
className="absolute pointer-events-none"
style={{ display: "none", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
{!isVisible && !isAnimating && (
<button
onClick={integrate}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 text-white flex items-center justify-center transition-colors shadow-lg"
aria-label="Restore"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
</div>
);
}
export { ParticleButton };import ParticleButton from "@/components/ParticleButton";
// Classic - default circle particles
<ParticleButton buttonClassName="bg-gray-900 text-white px-6 py-3 rounded-lg">
Send
</ParticleButton>
// Triangle particles with random black/white colors
<ParticleButton
particleType="triangle"
particleSize={6}
particleAmount={4}
oscillation={2}
particleColor={() => Math.random() < 0.5 ? "#000000" : "#ffffff"}
buttonClassName="bg-gray-800 text-white px-6 py-3 rounded-lg"
>
Upload
</ParticleButton>
// Rectangle particles going up
<ParticleButton
particleType="rectangle"
duration={500}
direction="top"
particleSize={8}
particleColor="#091388"
buttonClassName="bg-blue-900 text-white px-6 py-3 rounded-lg"
>
Submit
</ParticleButton>
// Burst effect - lots of small particles
<ParticleButton
duration={1300}
particleSize={3}
particleSpeed={1}
particleAmount={10}
oscillation={1}
buttonClassName="bg-amber-500 text-white px-6 py-3 rounded-lg"
>
Bookmark
</ParticleButton>
// Wave effect - slow wave motion
<ParticleButton
direction="right"
particleSize={4}
particleSpeed={1}
particleColor="#e85577"
particleAmount={1.5}
oscillation={1}
buttonClassName="bg-pink-500 text-white px-6 py-3 rounded-lg"
>
Delete
</ParticleButton>
// Confetti effect - stroke rectangles
<ParticleButton
particleType="rectangle"
particleStyle="stroke"
particleSize={15}
particleColor="#e87084"
duration={600}
oscillation={5}
particleAmount={2}
direction="right"
buttonClassName="bg-rose-400 text-white px-6 py-3 rounded-lg"
>
Subscribe
</ParticleButton>