Web InnoventixFreeCode

Ripple Effect

Original · free

A water-ripple ring expands from the cursor on hover.

byWeb InnoventixReact + Tailwind
rippleimageeffect
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/imgfx-ripple.json
imgfx-ripple.tsx
"use client";

import { useCallback, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";

type Ripple = { id: number; x: number; y: number };

export default function ImgfxRipple() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const idRef = useRef(0);
  const lastRef = useRef(0);
  const [ripples, setRipples] = useState<Ripple[]>([]);

  const spawn = useCallback(
    (clientX: number, clientY: number) => {
      if (reduce) return;
      const now = performance.now();
      if (now - lastRef.current < 70) return;
      lastRef.current = now;
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const x = ((clientX - rect.left) / rect.width) * 100;
      const y = ((clientY - rect.top) / rect.height) * 100;
      if (x < 0 || x > 100 || y < 0 || y > 100) return;
      const id = idRef.current++;
      setRipples((prev) => [...prev.slice(-11), { id, x, y }]);
    },
    [reduce]
  );

  const remove = useCallback((id: number) => {
    setRipples((prev) => prev.filter((r) => r.id !== id));
  }, []);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 sm:py-28 dark:bg-neutral-950">
      <style>{`
        @keyframes imgfxRippleRing {
          0%   { transform: translate(-50%, -50%) scale(0); opacity: 0.9; }
          70%  { opacity: 0.35; }
          100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
        }
        @keyframes imgfxRippleCore {
          0%   { transform: translate(-50%, -50%) scale(0); opacity: 0.55; }
          100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .imgfx-ripple-ring, .imgfx-ripple-core { animation: none !important; display: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <div className="mb-8 text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Image Effect / Ripple
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
            Water Ripple on Hover
          </h2>
          <p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400">
            Move your cursor across the photo &mdash; each pass sends a ring rippling out from the surface.
          </p>
        </div>

        <div
          ref={ref}
          onPointerMove={(e) => spawn(e.clientX, e.clientY)}
          onPointerDown={(e) => spawn(e.clientX, e.clientY)}
          className="group relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-neutral-200 shadow-xl shadow-neutral-900/10 ring-1 ring-black/5 dark:border-neutral-800 dark:shadow-black/40"
        >
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src="/img/gallery/g14.webp"
            alt="Sunlight scattering across the rippled surface of clear water"
            loading="lazy"
            draggable={false}
            className="absolute inset-0 h-full w-full select-none object-cover transition-transform duration-700 ease-out group-hover:scale-[1.03]"
          />

          <div className="pointer-events-none absolute inset-0">
            {ripples.map((r) => (
              <span
                key={r.id}
                onAnimationEnd={(e) => {
                  if (e.animationName === "imgfxRippleRing") remove(r.id);
                }}
                className="absolute block"
                style={{ left: `${r.x}%`, top: `${r.y}%` }}
              >
                <span
                  className="imgfx-ripple-ring absolute left-0 top-0 block h-40 w-40 rounded-full border border-white/70 mix-blend-overlay dark:border-white/60"
                  style={{
                    animation: "imgfxRippleRing 900ms cubic-bezier(0.22,1,0.36,1) forwards",
                    boxShadow: "0 0 24px 2px rgba(255,255,255,0.35) inset",
                  }}
                />
                <span
                  className="imgfx-ripple-core absolute left-0 top-0 block h-16 w-16 rounded-full bg-white/40 blur-md"
                  style={{ animation: "imgfxRippleCore 700ms ease-out forwards" }}
                />
              </span>
            ))}
          </div>

          <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-transparent" />

          <div className="pointer-events-none absolute bottom-3 left-4 right-4 flex items-center justify-between text-[11px] font-medium text-white/90">
            <span className="rounded-full bg-black/30 px-2.5 py-1 backdrop-blur-sm">g14.webp</span>
            <span className="rounded-full bg-black/30 px-2.5 py-1 backdrop-blur-sm">hover to disturb the surface</span>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-neutral-500 dark:text-neutral-500">
          Rings emit from the exact cursor point and fade as they widen. Reduced-motion keeps the water still.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →