Effects & Borders

Ripple Click

Material-style click ripples with a tasteful spring, as a reusable wrapper plus a useRipples hook.

Install

npx shadcn@latest add @paragon/ripple-click

ripple-click.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/**
 * RippleClick — material-style click ripples with a tasteful spring. Ships both
 * a reusable wrapper (`<RippleClick>`) and a hook (`useRipples`) so you can wire
 * ripples onto your own element.
 *
 * The ripple layer is `pointer-events-none` and `aria-hidden`, so it never
 * interferes with the wrapped control's semantics or focus. Ripples originate
 * at the exact pointer point (keyboard activation ripples from center). Under
 * reduced motion ripples are suppressed. Deterministic keys — no Date.now in
 * render.
 */

interface Ripple {
  key: number;
  x: number;
  y: number;
  size: number;
}

export interface UseRipplesOptions {
  color?: string;
  duration?: number;
  disabled?: boolean;
}

export function useRipples({
  color = "currentColor",
  duration = 0.6,
  disabled = false,
}: UseRipplesOptions = {}) {
  const [ripples, setRipples] = React.useState<Ripple[]>([]);
  const seq = React.useRef(0);
  const reduce = useReducedMotion();

  const spawn = React.useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      if (disabled || reduce) return;
      const el = e.currentTarget;
      const rect = el.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      // cover the farthest corner
      const size =
        2 *
        Math.max(
          Math.hypot(x, y),
          Math.hypot(rect.width - x, y),
          Math.hypot(x, rect.height - y),
          Math.hypot(rect.width - x, rect.height - y),
        );
      const key = seq.current++;
      setRipples((r) => [...r, { key, x, y, size }]);
    },
    [disabled, reduce],
  );

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

  const rippleLayer = (
    <span
      aria-hidden
      className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
    >
      <AnimatePresence>
        {ripples.map((r) => (
          <motion.span
            key={r.key}
            className="absolute rounded-full"
            style={{
              left: r.x,
              top: r.y,
              width: r.size,
              height: r.size,
              marginLeft: -r.size / 2,
              marginTop: -r.size / 2,
              background: color,
            }}
            initial={{ scale: 0, opacity: 0.35 }}
            animate={{ scale: 1, opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{
              scale: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
              opacity: { duration, ease: [0.22, 1, 0.36, 1] },
            }}
            onAnimationComplete={() => remove(r.key)}
          />
        ))}
      </AnimatePresence>
    </span>
  );

  return { spawn, rippleLayer, ripples } as const;
}

export interface RippleClickProps extends React.ComponentProps<"span"> {
  /** Ripple color (defaults to currentColor at low alpha). */
  color?: string;
  /** Fade duration in seconds. */
  duration?: number;
  /** Suppress ripples. */
  disabled?: boolean;
}

/**
 * Drop-in wrapper: renders an inline-flex `<span>` that emits a ripple on
 * pointer-down. Put a real `<button>`/`<a>` inside for semantics.
 */
export function RippleClick({
  color = "currentColor",
  duration = 0.6,
  disabled = false,
  className,
  children,
  onPointerDown,
  ...props
}: RippleClickProps) {
  const { spawn, rippleLayer } = useRipples({ color, duration, disabled });

  return (
    <span
      className={cn(
        "relative inline-flex overflow-hidden rounded-[inherit] isolate",
        className,
      )}
      onPointerDown={(e) => {
        spawn(e);
        onPointerDown?.(e);
      }}
      {...props}
    >
      {children}
      {rippleLayer}
    </span>
  );
}