# Ripple Button

> A button that sends a ripple out from exactly where it was pressed, or from the centre when pressed from the keyboard.

Category: Buttons · Free · Updated 2026-10-30 · Live demo: https://www.motionkitui.com/components/ripple-button

## When to use it

Forms, dashboards and apps, anywhere a press should feel acknowledged: save, submit and add buttons.

## How it works

Each press adds a circle centred on the press point, sized to reach the furthest corner, which scales up from zero while fading out, then removes itself when its animation completes. Presses from the keyboard report no pointer position, so they ripple from the centre. Ripples are independent, so quick presses overlap naturally.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/ripple-button.json
```

Or copy `components/motionkit/ripple-button.tsx` below. Requires react, motion and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rippleColor` | `string` | `"rgb(255 255 255 / 0.35)"` | Colour of the ripple. |
| `...props` | `ButtonHTMLAttributes` |  | Anything a <button> accepts. |

## Accessibility

- **Keyboard: Pass.** A native <button>; keyboard presses ripple from the centre, so they get the same feedback.
- **Screen readers: Pass.** Announced with its label; ripples are hidden from assistive tech.
- **Reduced motion: Pass.** With reduced motion on, no ripples are drawn.
- **Colour contrast: Pass.** White text measures 4.9:1 at rest and 6.6:1 on hover.

## Source

```tsx
"use client";

import { useState, type ComponentProps, type MouseEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type RippleButtonProps = ComponentProps<"button"> & {
  /** Colour of the ripple. */
  rippleColor?: string;
};

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

/**
 * A button that sends a ripple out from exactly where it was pressed, or from the centre when pressed with
 * the keyboard. Quick presses stack ripples, and each one fades as it spreads.
 */
export function RippleButton({ rippleColor = "rgb(255 255 255 / 0.35)", className = "", children, onClick, ...props }: RippleButtonProps) {
  const reduceMotion = useReducedMotion();
  const [ripples, setRipples] = useState<Ripple[]>([]);

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (reduceMotion) return;
    const rect = event.currentTarget.getBoundingClientRect();
    // a keyboard press reports no pointer position, so it ripples from the middle
    const fromKeyboard = event.detail === 0;
    const x = fromKeyboard ? rect.width / 2 : event.clientX - rect.left;
    const y = fromKeyboard ? rect.height / 2 : event.clientY - rect.top;
    // big enough to reach the furthest corner from the press
    const size = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2;
    const id = performance.now();
    setRipples((list) => [...list, { id, x, y, size }]);
  }

  return (
    <button
      {...props}
      onClick={handleClick}
      className={`relative isolate inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-[#0E7C86] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[#0B6770] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] disabled:pointer-events-none disabled:opacity-50 ${className}`}
    >
      <AnimatePresence>
        {ripples.map((ripple) => (
          <motion.span
            key={ripple.id}
            aria-hidden
            className="pointer-events-none absolute -z-10 rounded-full"
            style={{ left: ripple.x - ripple.size / 2, top: ripple.y - ripple.size / 2, width: ripple.size, height: ripple.size, backgroundColor: rippleColor }}
            initial={{ scale: 0, opacity: 1 }}
            animate={{ scale: 1, opacity: 0 }}
            transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
            onAnimationComplete={() => setRipples((list) => list.filter((item) => item.id !== ripple.id))}
          />
        ))}
      </AnimatePresence>
      {children}
    </button>
  );
}
```
