# Ripple — Animated Button for React

> A Material-style ink ripple that blooms from the exact point of contact, with a spring press-dip and rebound. Concurrent ripples stack; keyboard activation blooms from center.

- Page: https://crucibleui.com/components/buttons/ripple
- Category: [buttons](https://crucibleui.com/components/buttons)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/ripple
```

Registry JSON: https://crucibleui.com/r/ripple.json

Import from `@/components/crucible/ripple`.

## Props (`RippleProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color?` | `string` | `soft white` | Ripple ink color. Any CSS color; drawn at low opacity so it tints, never covers. |
| `size?` | `RippleSize` | `"md"` | Padding / font scale of the button. |
| `intensity?` | `number` | `0.35` | Peak opacity of the expanding ink, 0–1. Lower reads more restrained. |
| `duration?` | `number` | `0.6` | Seconds an ink drop takes to expand across the face and fade. |
| `radius?` | `number` | `10` | Corner radius in px. |
| `asChild?` | `boolean` | `false` | Render the passed child as the root element instead of a `<button>`, merging in the ripple layer, press feedback, className, style and refs. The child must forward props to a real interactive element. |
| `ref?` | `React.Ref<RippleHandle>` | — | Imperative handle — see {@link RippleHandle}. Note this is the handle, not the DOM node (Ripple never forwarded its element ref). |

Also accepts: `React.ComponentPropsWithoutRef<"button">`.

## Usage example

```tsx
import { Ripple } from "@/components/crucible/ripple";

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function RippleDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.03),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-10">
        <div className="flex flex-wrap items-end justify-center gap-8">
          <div className="flex flex-col items-center gap-3">
            <Ripple>Click anywhere</Ripple>
            <span className={LABEL}>Default</span>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Ripple color="rgba(56,189,248,0.9)" size="lg" intensity={0.45}>
              Submit
            </Ripple>
            <span className={LABEL}>Accent · large</span>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Ripple size="sm">Small</Ripple>
            <span className={LABEL}>Small</span>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Ripple disabled>Disabled</Ripple>
            <span className={LABEL}>Disabled</span>
          </div>
        </div>
        <p className="text-sm text-neutral-500">
          Click anywhere on a button — the ink blooms from your exact point of contact. Tab and press
          Enter to ripple from center.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`ripple.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, type HTMLMotionProps } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";

export type RippleSize = "sm" | "md" | "lg";

export interface RippleProps extends React.ComponentPropsWithoutRef<"button"> {
  /** Ripple ink color. Any CSS color; drawn at low opacity so it tints, never covers. @default soft white */
  color?: string;
  /** Padding / font scale of the button. @default "md" */
  size?: RippleSize;
  /** Peak opacity of the expanding ink, 0–1. Lower reads more restrained. @default 0.35 */
  intensity?: number;
  /** Seconds an ink drop takes to expand across the face and fade. @default 0.6 */
  duration?: number;
  /** Corner radius in px. @default 10 */
  radius?: number;
  /**
   * Render the passed child as the root element instead of a `<button>`,
   * merging in the ripple layer, press feedback, className, style and refs.
   * The child must forward props to a real interactive element. @default false
   */
  asChild?: boolean;
  /**
   * Imperative handle — see {@link RippleHandle}. Note this is the handle, not
   * the DOM node (Ripple never forwarded its element ref).
   */
  ref?: React.Ref<RippleHandle>;
}

/** Imperative controls exposed through the `ref` prop. */
export interface RippleHandle {
  /**
   * Bloom an ink drop without clicking: purely visual, fires no click and
   * never moves focus. `x`/`y` are px within the button (default: center).
   * Stacks with any drops already in flight.
   */
  pulse(x?: number, y?: number): void;
}

/** Concurrent drops are capped; the oldest is retired when a new one lands. */
const MAX_DROPS = 8;

interface Drop {
  id: number;
  /** Origin x within the button, in px. */
  x: number;
  /** Origin y within the button, in px. */
  y: number;
  /** Diameter that covers the whole face from this origin, in px. */
  size: number;
}

const SIZE_CLASSES: Record<RippleSize, string> = {
  sm: "px-4 py-2 text-xs gap-1.5",
  md: "px-6 py-3 text-sm gap-2",
  lg: "px-8 py-4 text-base gap-2.5",
};

/** Merge any number of refs (callback or object) into one callback ref. */
function mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T> {
  return (value) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(value);
      else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
    }
  };
}

/** Compose two event handlers; the injected one runs first, then the child's own. */
function composeHandlers<E>(
  theirs: ((event: E) => void) | undefined,
  ours: (event: E) => void
): (event: E) => void {
  return (event) => {
    ours(event);
    theirs?.(event);
  };
}

/**
 * Ripple — a Material-style ink ripple. On press a soft circle blooms from the
 * exact point of contact and fades; several presses stack their own concurrent
 * ripples (capped at 8). The face dips on a spring while held and rebounds on release.
 * Keyboard activation (Enter/Space) blooms from the center. Neutral-elegant by
 * default with a single `color` accent; `size` and `intensity` are the knobs.
 * Under prefers-reduced-motion the expanding bloom is replaced by one quiet,
 * full-face opacity flash — motion-free, never blank.
 */
export function Ripple({
  color = "rgba(255,255,255,0.9)",
  size = "md",
  intensity = 0.35,
  duration = 0.6,
  radius = 10,
  asChild = false,
  className,
  style,
  disabled,
  children,
  onPointerDown,
  onPointerUp,
  onPointerLeave,
  onClick,
  ref,
  ...rest
}: RippleProps) {
  const reducedMotion = useReducedMotion();
  const rootRef = React.useRef<HTMLElement>(null);
  const [drops, setDrops] = React.useState<Drop[]>([]);
  const [pressed, setPressed] = React.useState(false);
  const nextId = React.useRef(0);

  const timers = React.useRef(new Set<number>());
  React.useEffect(() => {
    const set = timers.current;
    return () => {
      for (const t of set) window.clearTimeout(t);
      set.clear();
    };
  }, []);

  /** Add one drop at (x, y) in button-local px; null = center. */
  const bloom = React.useCallback(
    (lx: number | null, ly: number | null) => {
      const el = rootRef.current;
      if (!el) return;
      // Layout size, not the transformed rect — the face may be mid press-dip.
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      const x = lx ?? w / 2;
      const y = ly ?? h / 2;
      // Diameter that reaches the farthest corner, so the ink always fills.
      const dia = Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) * 2;

      const id = nextId.current++;
      setDrops((prev) => {
        const next = [...prev, { id, x, y, size: dia }];
        return next.length > MAX_DROPS ? next.slice(next.length - MAX_DROPS) : next;
      });
      const t = window.setTimeout(
        () => {
          timers.current.delete(t);
          setDrops((prev) => prev.filter((d) => d.id !== id));
        },
        // Keep the node past its animation (1.1x) so the fade completes.
        (reducedMotion ? 0.36 : duration) * 1100
      );
      timers.current.add(t);
    },
    [duration, reducedMotion]
  );

  const spawn = React.useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      const el = rootRef.current;
      if (!el) return;
      // Keyboard-activated clicks report detail 0 and no real pointer position —
      // bloom from the exact center in that case.
      if (event.detail === 0) return bloom(null, null);
      const rect = el.getBoundingClientRect();
      // Map through any press-dip scale so the drop lands under the finger.
      const sx = rect.width / (el.offsetWidth || rect.width || 1);
      const sy = rect.height / (el.offsetHeight || rect.height || 1);
      bloom((event.clientX - rect.left) / sx, (event.clientY - rect.top) / sy);
    },
    [bloom]
  );

  React.useImperativeHandle(
    ref,
    () => ({
      pulse(x?: number, y?: number) {
        if (disabled) return;
        bloom(x ?? null, y ?? null);
      },
    }),
    [bloom, disabled]
  );

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    if (!disabled) spawn(event);
    onClick?.(event as React.MouseEvent<HTMLButtonElement>);
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLElement>) => {
    if (!disabled) setPressed(true);
    onPointerDown?.(event as React.PointerEvent<HTMLButtonElement>);
  };
  const release = () => setPressed(false);
  const handlePointerUp = (event: React.PointerEvent<HTMLElement>) => {
    release();
    onPointerUp?.(event as React.PointerEvent<HTMLButtonElement>);
  };
  const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
    release();
    onPointerLeave?.(event as React.PointerEvent<HTMLButtonElement>);
  };

  const pressTransition = reducedMotion
    ? { duration: 0.12, ease: "easeOut" as const }
    : { type: "spring" as const, stiffness: 420, damping: 24 };

  const rootClassName = cn(
    "relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-[inherit]",
    "border border-neutral-800 bg-neutral-900 font-medium text-white",
    "transition-colors duration-200 hover:border-neutral-700 hover:bg-neutral-800",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
    "focus-visible:ring-[color:var(--ripple-color)]",
    "disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
    SIZE_CLASSES[size],
    className
  );

  const rootStyle = {
    borderRadius: radius,
    "--ripple-color": color,
    ...style,
  } as React.CSSProperties;

  // The ink layer + label, shared by both the <button> and asChild paths.
  const inkLayer = (
    <span
      aria-hidden
      className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
    >
      <AnimatePresence>
        {drops.map((drop) =>
          reducedMotion ? (
            // Reduced motion: a single quiet full-face flash, pinned to an
            // explicit resting pose (opacity 0) so it never freezes visible.
            <motion.span
              key={drop.id}
              className="absolute inset-0 rounded-[inherit]"
              style={{ background: color }}
              initial={{ opacity: intensity * 0.55 }}
              animate={{ opacity: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.36, ease: "easeOut" }}
            />
          ) : (
            <motion.span
              key={drop.id}
              className="absolute rounded-full"
              style={{
                left: drop.x,
                top: drop.y,
                translateX: "-50%",
                translateY: "-50%",
                background: `radial-gradient(circle, ${color} 0%, ${color} 58%, transparent 72%)`,
              }}
              initial={{ width: 0, height: 0, opacity: intensity }}
              animate={{ width: drop.size, height: drop.size, opacity: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration, ease: [0.16, 0.6, 0.32, 1] }}
            />
          )
        )}
      </AnimatePresence>
    </span>
  );

  const scale = pressed && !disabled && !reducedMotion ? 0.96 : 1;

  if (asChild) {
    const child = React.Children.only(children) as React.ReactElement<
      React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }
    >;
    const childProps = child.props;
    return React.cloneElement(
      child,
      {
        // Forward the rest of Ripple's own props (aria-*, data-*, handlers…);
        // the child's same-named props still win.
        ...Object.fromEntries(Object.entries(rest).filter(([key]) => !(key in childProps))),
        "aria-disabled": disabled || undefined,
        ref: mergeRefs(rootRef, (child as unknown as { ref?: React.Ref<HTMLElement> }).ref),
        className: cn(rootClassName, childProps.className),
        style: {
          ...rootStyle,
          ...childProps.style,
          transform: `scale(${scale})`,
          transition: reducedMotion
            ? "transform 0.12s ease-out"
            : "transform 0.42s cubic-bezier(0.34,1.56,0.64,1)",
        },
        onClick: composeHandlers(childProps.onClick, handleClick),
        onPointerDown: composeHandlers(childProps.onPointerDown, handlePointerDown),
        onPointerUp: composeHandlers(childProps.onPointerUp, handlePointerUp),
        onPointerLeave: composeHandlers(childProps.onPointerLeave, handlePointerLeave),
      } as React.HTMLAttributes<HTMLElement> & { ref: React.Ref<HTMLElement> },
      <>
        {inkLayer}
        <span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
          {childProps.children}
        </span>
      </>
    );
  }

  return (
    <motion.button
      ref={rootRef as React.Ref<HTMLButtonElement>}
      data-crucible="ripple"
      className={rootClassName}
      style={rootStyle}
      disabled={disabled}
      animate={{ scale }}
      transition={pressTransition}
      onClick={handleClick}
      onPointerDown={handlePointerDown}
      onPointerUp={handlePointerUp}
      onPointerLeave={handlePointerLeave}
      {...(rest as unknown as HTMLMotionProps<"button">)}
    >
      {inkLayer}
      <span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
        {children}
      </span>
    </motion.button>
  );
}
```
