返回动效库
指针进阶

涟漪点击

点击时从触发点产生向外扩散的圆形涟漪效果,Material Design 风格的经典交互反馈。

点击时600ms

交互预览

动效定义与代码

{
  "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}"
    }
  ]
}