All components

Ripple Button

Expands a ripple from the exact point you pressed.

1.2 KB gzippedZero dependenciesReduced-motion safeBase UI & Radix compatible

Installation

npx shadcn@latest add https://innsite.com.mx/r/ripple-button.json

Source

"use client";

import { useState } from "react";

import { cn } from "@/lib/utils";

interface RippleButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Milliseconds for a ripple to finish expanding */
  duration?: number;
  /** Any CSS colour; defaults to a translucent version of the text colour */
  rippleColor?: string;
  children: React.ReactNode;
}

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

/**
 * Button that expands a ripple from the exact point you pressed.
 * Ripples are skipped entirely under `prefers-reduced-motion`.
 */
export function RippleButton({
  duration = 600,
  rippleColor = "currentColor",
  children,
  className,
  onClick,
  ...props
}: RippleButtonProps) {
  const [ripples, setRipples] = useState<Ripple[]>([]);

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!reduced) {
      const rect = event.currentTarget.getBoundingClientRect();
      // Diameter that reaches the furthest corner from the press point.
      const size = Math.max(rect.width, rect.height) * 2;
      const ripple: Ripple = {
        id: Date.now() + Math.random(),
        x: event.clientX - rect.left - size / 2,
        y: event.clientY - rect.top - size / 2,
        size,
      };
      setRipples((current) => [...current, ripple]);
      window.setTimeout(
        () => setRipples((current) => current.filter((r) => r.id !== ripple.id)),
        duration
      );
    }
    onClick?.(event);
  };

  return (
    <button
      {...props}
      data-slot="ripple-button"
      onClick={handleClick}
      className={cn(
        "relative inline-flex h-11 cursor-pointer items-center justify-center overflow-hidden rounded-xl border bg-background px-6 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50",
        className
      )}
    >
      <span className="relative z-10">{children}</span>
      <span aria-hidden className="pointer-events-none absolute inset-0">
        {ripples.map((ripple) => (
          <span
            key={ripple.id}
            className="absolute animate-ripple rounded-full opacity-30"
            style={{
              left: ripple.x,
              top: ripple.y,
              width: ripple.size,
              height: ripple.size,
              background: rippleColor,
              animationDuration: `${duration}ms`,
            }}
          />
        ))}
      </span>
    </button>
  );
}