彩纸爆炸
点击时从触发点向四周射出彩色纸片粒子,适合成功状态、庆祝和游戏化场景的视觉反馈。
交互预览
动效定义与代码
{
"slug": "confetti-burst",
"name": "彩纸爆炸",
"nameEn": "Confetti Burst",
"description": "点击时从触发点向四周射出彩色纸片粒子,适合成功状态、庆祝和游戏化场景的视觉反馈。",
"descriptionEn": "Colorful confetti particles burst outward from the click point and float down with gravity. Perfect for success states, celebrations, and gamification feedback.",
"category": "micro-interaction",
"tags": [
"confetti",
"particle",
"celebration",
"click",
"success",
"gamification"
],
"trigger": "on-click",
"difficulty": "advanced",
"duration": "1.2s",
"easing": "cubic-bezier(0, 0.9, 0.57, 1)",
"cssProperties": [
"transform",
"opacity"
],
"isGPUAccelerated": true,
"previewBg": "light",
"keywords": [
"confetti",
"celebration",
"particle",
"burst",
"success",
"reward",
"gamification"
],
"useCases": [
"Form submission success",
"Achievement unlocked",
"Payment completed",
"Goal reached indicator"
],
"relatedAnimations": [
"ripple-click",
"pulse-ring",
"elastic-snap"
],
"recommendedStyles": [
"dopamine-design",
"kawaii-minimal",
"memphis",
"pop-art"
],
"codeSnippets": [
{
"label": "CSS Keyframes",
"language": "css",
"code": "@keyframes confetti-fall {\n 0% {\n opacity: 1;\n transform: translate(var(--x), var(--y)) rotate(0deg) scale(1);\n }\n 100% {\n opacity: 0;\n transform: translate(\n calc(var(--x) + var(--drift)),\n calc(var(--y) + 120px)\n ) rotate(var(--spin)) scale(0.4);\n }\n}\n\n.confetti-particle {\n position: absolute;\n width: 8px;\n height: 8px;\n will-change: transform, opacity;\n animation: confetti-fall 1.2s cubic-bezier(0, 0.9, 0.57, 1) forwards;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .confetti-particle {\n animation: none;\n display: none;\n }\n}"
},
{
"label": "Tailwind CSS",
"language": "css",
"code": "/* Confetti burst — JS generates particles, CSS animates them. */\n\n@keyframes confetti-fall {\n 0% {\n opacity: 1;\n transform: translate(var(--x), var(--y)) rotate(0deg) scale(1);\n }\n 100% {\n opacity: 0;\n transform: translate(\n calc(var(--x) + var(--drift)),\n calc(var(--y) + 120px)\n ) rotate(var(--spin)) scale(0.4);\n }\n}\n\n@utility animate-confetti {\n position: absolute;\n width: 8px;\n height: 8px;\n will-change: transform, opacity;\n animation: confetti-fall 1.2s cubic-bezier(0, 0.9, 0.57, 1) forwards;\n pointer-events: none;\n}"
},
{
"label": "AnimeJS",
"language": "tsx",
"code": "\"use client\";\n\nimport { useEffect, useRef, type MouseEvent, type ReactNode } from \"react\";\n\nconst COLORS = [\"#ff006e\", \"#fb5607\", \"#ffbe0b\", \"#8338ec\", \"#3a86ff\", \"#06d6a0\"];\nconst PARTICLE_COUNT = 26;\n\ntype AnimeModule = typeof import(\"animejs\");\ntype AnimeAnimation = {\n cancel(): unknown;\n};\n\nfunction prefersReducedMotion() {\n return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport function ConfettiButton({ children }: { children: ReactNode }) {\n const buttonRef = useRef<HTMLButtonElement>(null);\n const animeRef = useRef<Promise<AnimeModule> | null>(null);\n const activeParticlesRef = useRef<Array<{ animation: AnimeAnimation; element: HTMLSpanElement }>>([]);\n\n useEffect(() => {\n return () => {\n activeParticlesRef.current.forEach(({ animation, element }) => {\n animation.cancel();\n element.remove();\n });\n activeParticlesRef.current = [];\n };\n }, []);\n\n function getAnime() {\n animeRef.current ??= import(\"animejs\");\n return animeRef.current;\n }\n\n function createParticle(button: HTMLButtonElement, x: number, y: number, index: number) {\n const particle = document.createElement(\"span\");\n particle.className = \"pointer-events-none absolute block will-change-transform\";\n Object.assign(particle.style, {\n background: COLORS[index % COLORS.length],\n borderRadius: Math.random() > 0.45 ? \"50%\" : \"1px\",\n height: \"8px\",\n left: x - 4 + \"px\",\n top: y - 4 + \"px\",\n width: \"8px\",\n });\n button.appendChild(particle);\n return particle;\n }\n\n function removeParticle(particle: HTMLSpanElement) {\n activeParticlesRef.current = activeParticlesRef.current.filter(\n (entry) => entry.element !== particle\n );\n particle.remove();\n }\n\n async function handleClick(event: MouseEvent<HTMLButtonElement>) {\n const button = event.currentTarget;\n const rect = button.getBoundingClientRect();\n const originX = event.clientX - rect.left;\n const originY = event.clientY - rect.top;\n if (prefersReducedMotion()) return;\n\n const { animate } = await getAnime();\n if (buttonRef.current !== button) return;\n\n animate(button, { scale: [0.97, 1], duration: 220, ease: \"out(3)\" });\n\n Array.from({ length: PARTICLE_COUNT }, (_, index) => {\n const angle = (Math.PI * 2 * index) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.5;\n const velocity = 58 + Math.random() * 90;\n const particle = createParticle(button, originX, originY, index);\n const animation = animate(particle, {\n x: [0, Math.cos(angle) * velocity],\n y: [0, Math.sin(angle) * velocity + 104],\n rotate: [\"0deg\", Math.random() * 720 - 360 + \"deg\"],\n scale: [1, 0.35],\n opacity: [1, 0],\n duration: 1050 + Math.random() * 220,\n delay: index * 5,\n ease: \"out(3)\",\n onComplete: () => removeParticle(particle),\n });\n activeParticlesRef.current.push({ animation, element: particle });\n });\n }\n\n return (\n <button\n ref={buttonRef}\n onClick={handleClick}\n className=\"relative overflow-visible px-6 py-3 bg-zinc-900 text-white\"\n >\n {children}\n </button>\n );\n}"
},
{
"label": "Framer Motion",
"language": "tsx",
"code": "\"use client\";\n\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { useState, type MouseEvent } from \"react\";\n\nconst COLORS = [\"#ff006e\", \"#fb5607\", \"#ffbe0b\", \"#8338ec\", \"#3a86ff\", \"#06d6a0\"];\n\ninterface Particle {\n id: number;\n x: number;\n y: number;\n color: string;\n angle: number;\n velocity: number;\n}\n\nfunction spawnParticles(x: number, y: number, count = 20): Particle[] {\n return Array.from({ length: count }, (_, i) => ({\n id: Date.now() + i,\n x,\n y,\n color: COLORS[Math.floor(Math.random() * COLORS.length)],\n angle: (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5,\n velocity: 60 + Math.random() * 80,\n }));\n}\n\nexport function ConfettiButton({ children }: { children: React.ReactNode }) {\n const [particles, setParticles] = useState<Particle[]>([]);\n\n function handleClick(e: MouseEvent<HTMLButtonElement>) {\n const rect = e.currentTarget.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n setParticles((prev) => [...prev, ...spawnParticles(x, y)]);\n }\n\n return (\n <button onClick={handleClick} style={{ position: \"relative\", overflow: \"visible\" }}>\n {children}\n <AnimatePresence>\n {particles.map((p) => (\n <motion.span\n key={p.id}\n initial={{\n x: p.x,\n y: p.y,\n scale: 1,\n opacity: 1,\n }}\n animate={{\n x: p.x + Math.cos(p.angle) * p.velocity,\n y: p.y + Math.sin(p.angle) * p.velocity + 120,\n scale: 0.4,\n opacity: 0,\n rotate: Math.random() * 720 - 360,\n }}\n exit={{ opacity: 0 }}\n transition={{ duration: 1.2, ease: [0, 0.9, 0.57, 1] }}\n onAnimationComplete={() =>\n setParticles((prev) => prev.filter((pp) => pp.id !== p.id))\n }\n style={{\n position: \"absolute\",\n width: 8,\n height: 8,\n borderRadius: Math.random() > 0.5 ? \"50%\" : \"1px\",\n background: p.color,\n pointerEvents: \"none\",\n }}\n />\n ))}\n </AnimatePresence>\n </button>\n );\n}"
}
]
}