# Ripple Button

> Material style ripple button that spreads a wave from the pointer position and cleans up afterwards.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/ripple-button
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ripple-button.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/ripple-button-demo.tsx"
import { RippleButton } from "@/components/ripple-button"

export default function RippleButtonDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4 rounded-2xl bg-neutral-100 p-12 dark:bg-neutral-900">
      <RippleButton>Click anywhere</RippleButton>
      <RippleButton className="bg-emerald-700 hover:bg-emerald-600" rippleColor="rgba(255,255,255,0.6)">
        Deploy
      </RippleButton>
    </div>
  )
}
```

## Source

```tsx title="components/ripple-button.tsx"
"use client"

import * as React from "react"

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

const keyframes = `@keyframes ripple-button-wave{from{transform:scale(0);opacity:.45}to{transform:scale(1);opacity:0}}`

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

export interface RippleButtonProps extends React.ComponentProps<"button"> {
  rippleColor?: string
}

export function RippleButton({
  rippleColor = "rgba(255,255,255,0.7)",
  onPointerDown,
  className,
  children,
  ...props
}: RippleButtonProps) {
  const [ripples, setRipples] = React.useState<Ripple[]>([])
  const counter = React.useRef(0)

  return (
    <button
      type="button"
      onPointerDown={(event) => {
        onPointerDown?.(event)
        const rect = event.currentTarget.getBoundingClientRect()
        const size = Math.max(rect.width, rect.height) * 2
        counter.current += 1
        const id = counter.current
        setRipples((current) => [
          ...current,
          { id, size, x: event.clientX - rect.left - size / 2, y: event.clientY - rect.top - size / 2 },
        ])
      }}
      className={cn(
        "relative inline-flex h-11 items-center justify-center gap-2 overflow-hidden rounded-lg bg-indigo-600 px-7 text-sm font-medium text-white select-none",
        "shadow-sm transition-colors hover:bg-indigo-500",
        "focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:outline-none",
        "disabled:pointer-events-none disabled:opacity-50",
        className
      )}
      {...props}
    >
      <style href="ripple-button" precedence="default">
        {keyframes}
      </style>
      <span className="relative z-10 inline-flex items-center gap-2">{children}</span>
      {ripples.map((ripple) => (
        <span
          key={ripple.id}
          aria-hidden
          onAnimationEnd={() => setRipples((current) => current.filter((item) => item.id !== ripple.id))}
          className="pointer-events-none absolute rounded-full"
          style={{
            left: ripple.x,
            top: ripple.y,
            width: ripple.size,
            height: ripple.size,
            backgroundColor: rippleColor,
            animationName: "ripple-button-wave",
            animationDuration: "650ms",
            animationTimingFunction: "ease-out",
            animationFillMode: "forwards",
          }}
        />
      ))}
    </button>
  )
}
```
