Robert Crocker

Craft obsessed developer who designs.

← Back to the lab

07Ripple Button

I · Interaction Studies

Two staggered keyframes spawn a wave at the exact click point.

Click

Source

1 file
"use client";

import { useCallback, useRef, useState } from "react";

interface Ripple {
  id: number;
  x: number;
  y: number;
}

export function RippleExhibit() {
  const [ripples, setRipples] = useState<Ripple[]>([]);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const nextId = useRef(0);

  const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
    // Respect reduced motion preferences
    if (!window.matchMedia("(prefers-reduced-motion: no-preference)").matches) {
      return;
    }

    const button = buttonRef.current;
    if (!button) return;

    const bb = button.getBoundingClientRect();
    setRipples((prev) => [
      ...prev,
      { id: nextId.current++, x: event.clientX - bb.left, y: event.clientY - bb.top },
    ]);
  }, []);

  const handleAnimationEnd = useCallback((id: number, animationName: string) => {
    // Remove only after the fade completes, not after grow
    if (animationName === "labRippleFade") {
      setRipples((prev) => prev.filter((r) => r.id !== id));
    }
  }, []);

  return (
    <div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(hsl(var(--muted-foreground)/0.12)_1px,transparent_1px)] [background-size:24px_24px]">
      <style>{`
        @keyframes labRippleGrow {
          from { transform: scale(0); }
          to   { transform: scale(1); }
        }
        @keyframes labRippleFade {
          to { opacity: 0; }
        }
      `}</style>

      <button
        ref={buttonRef}
        type="button"
        onClick={handleClick}
        className="relative overflow-hidden rounded bg-foreground px-8 py-3 text-base font-medium text-background transition-transform duration-150 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
      >
        Click me
        {ripples.map((ripple) => (
          <span
            key={ripple.id}
            onAnimationEnd={(e) => handleAnimationEnd(ripple.id, e.animationName)}
            className="pointer-events-none absolute size-24 -translate-x-1/2 -translate-y-1/2 rounded-full bg-background opacity-20"
            style={{
              left: ripple.x,
              top: ripple.y,
              animation:
                "labRippleGrow 400ms forwards cubic-bezier(0.4, 0.4, 0, 1), labRippleFade 400ms 200ms forwards",
            }}
          />
        ))}
      </button>
    </div>
  );
}