涟漪点击
点击时从触发点产生向外扩散的圆形涟漪效果,Material Design 风格的经典交互反馈。
交互预览
动效定义与代码
{
"slug": "ripple-click",
"name": "涟漪点击",
"nameEn": "Ripple Click",
"description": "点击时从触发点产生向外扩散的圆形涟漪效果,Material Design 风格的经典交互反馈。",
"descriptionEn": "A circular ripple expands outward from the click point and fades out. Classic Material Design interaction feedback.",
"category": "micro-interaction",
"tags": [
"ripple",
"click",
"material",
"feedback",
"interactive"
],
"trigger": "on-click",
"difficulty": "intermediate",
"duration": "600ms",
"easing": "ease-out",
"cssProperties": [
"transform",
"opacity"
],
"isGPUAccelerated": true,
"previewBg": "light",
"keywords": [
"ripple",
"click",
"material design",
"wave",
"touch feedback",
"button click"
],
"useCases": [
"Button click feedback",
"List item tap effect",
"Material Design interactions",
"Touch-friendly UI elements"
],
"relatedAnimations": [
"shake",
"magnetic-hover"
],
"recommendedStyles": [
"material-design",
"minimalist-flat",
"soft-ui"
],
"codeSnippets": [
{
"label": "CSS Keyframes",
"language": "css",
"code": "@keyframes ripple-click {\n from {\n opacity: 0.6;\n transform: scale(0);\n }\n to {\n opacity: 0;\n transform: scale(4);\n }\n}\n\n.ripple-container {\n position: relative;\n overflow: hidden;\n}\n\n.ripple-effect {\n position: absolute;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n margin-top: -20px;\n margin-left: -20px;\n background: var(--ripple-color, hsl(0 0% 0% / 0.15));\n will-change: transform, opacity;\n animation: ripple-click 600ms ease-out forwards;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ripple-effect {\n animation: none;\n }\n}"
},
{
"label": "Tailwind CSS",
"language": "css",
"code": "/* Add to your global CSS or Tailwind v4 theme */\n@keyframes ripple-click {\n from {\n opacity: 0.6;\n transform: scale(0);\n }\n to {\n opacity: 0;\n transform: scale(4);\n }\n}\n\n@utility ripple-container {\n position: relative;\n overflow: hidden;\n}\n\n@utility animate-ripple-click {\n position: absolute;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n margin-top: -20px;\n margin-left: -20px;\n background: var(--ripple-color, hsl(0 0% 0% / 0.15));\n will-change: transform, opacity;\n animation: ripple-click 600ms ease-out forwards;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ripple-effect {\n animation: none;\n }\n}"
},
{
"label": "AnimeJS",
"language": "tsx",
"code": "\"use client\";\n\nimport { useCallback, useEffect, useRef, type MouseEvent, type ReactNode } from \"react\";\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 RippleButton({ children }: { children: ReactNode }) {\n const buttonRef = useRef<HTMLButtonElement>(null);\n const animeRef = useRef<Promise<AnimeModule> | null>(null);\n const activeRipplesRef = useRef<Array<{ animation: AnimeAnimation; element: HTMLSpanElement }>>([]);\n\n useEffect(() => {\n return () => {\n activeRipplesRef.current.forEach(({ animation, element }) => {\n animation.cancel();\n element.remove();\n });\n activeRipplesRef.current = [];\n };\n }, []);\n\n const getAnime = useCallback(() => {\n animeRef.current ??= import(\"animejs\");\n return animeRef.current;\n }, []);\n\n const removeRipple = useCallback((ripple: HTMLSpanElement) => {\n activeRipplesRef.current = activeRipplesRef.current.filter(\n (entry) => entry.element !== ripple\n );\n ripple.remove();\n }, []);\n\n const spawnRipple = useCallback(\n async (x: number, y: number) => {\n const button = buttonRef.current;\n if (!button || prefersReducedMotion()) return;\n\n const { animate } = await getAnime();\n if (buttonRef.current !== button) return;\n\n const ripple = document.createElement(\"span\");\n ripple.style.cssText =\n \"position:absolute;left:\" + (x - 12) + \"px;top:\" + (y - 12) + \"px;width:24px;height:24px;border-radius:9999px;background:currentColor;opacity:0.45;pointer-events:none;will-change:transform;\";\n button.appendChild(ripple);\n\n const animation = animate(ripple, {\n scale: [0, 16],\n opacity: [0.45, 0],\n duration: 650,\n ease: \"out(2)\",\n onComplete: () => removeRipple(ripple),\n });\n\n activeRipplesRef.current.push({ animation, element: ripple });\n },\n [getAnime, removeRipple]\n );\n\n function handleClick(event: MouseEvent<HTMLButtonElement>) {\n const rect = event.currentTarget.getBoundingClientRect();\n void spawnRipple(event.clientX - rect.left, event.clientY - rect.top);\n }\n\n return (\n <button\n ref={buttonRef}\n onClick={handleClick}\n className=\"relative overflow-hidden px-6 py-3 bg-indigo-500 text-white\"\n >\n <span className=\"relative z-10 pointer-events-none\">{children}</span>\n </button>\n );\n}"
},
{
"label": "Framer Motion",
"language": "tsx",
"code": "import { motion, AnimatePresence } from \"framer-motion\";\nimport { useState, type MouseEvent } from \"react\";\n\n// Tip: respect user's motion preferences\n// import { useReducedMotion } from \"framer-motion\";\n// const reduced = useReducedMotion();\n// If reduced, skip ripple and rely on :active state change\n\nfunction RippleButton({ children }: { children: React.ReactNode }) {\n const [ripples, setRipples] = useState<{ x: number; y: number; id: number }[]>([]);\n\n function handleClick(e: MouseEvent<HTMLButtonElement>) {\n const rect = e.currentTarget.getBoundingClientRect();\n setRipples((prev) => [\n ...prev,\n { x: e.clientX - rect.left, y: e.clientY - rect.top, id: Date.now() },\n ]);\n }\n\n return (\n <button onClick={handleClick} style={{ position: \"relative\", overflow: \"hidden\" }}>\n {children}\n <AnimatePresence>\n {ripples.map((r) => (\n <motion.span\n key={r.id}\n initial={{ scale: 0, opacity: 0.6 }}\n animate={{ scale: 4, opacity: 0 }}\n exit={{ opacity: 0 }}\n transition={{ duration: 0.6, ease: \"easeOut\" }}\n onAnimationComplete={() =>\n setRipples((prev) => prev.filter((p) => p.id !== r.id))\n }\n style={{\n position: \"absolute\",\n left: r.x - 20,\n top: r.y - 20,\n width: 40,\n height: 40,\n borderRadius: \"50%\",\n background: \"currentColor\",\n opacity: 0.15,\n pointerEvents: \"none\",\n }}\n />\n ))}\n </AnimatePresence>\n </button>\n );\n}"
}
]
}