niceui

Ripple Button New

Ripples spread from the click point, then the button moves through loading and success.

ReactTailwind CSSTypeScriptEffectsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/effects-ripple-button.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/effects/RippleButton.tsx
import { useEffect, useRef, useState, type MouseEvent } from 'react';
import { Check, LoaderCircle, Send } from 'lucide-react';
import { cn } from '@/lib/cn';

const css = `@keyframes nui-ripple{from{transform:scale(0);opacity:.45}to{transform:scale(1);opacity:0}}`;
type State = 'idle' | 'loading' | 'success';

export type RippleButtonProps = { onAction?: () => Promise<void> | void; label?: string; className?: string };

/** Ripples spread from the click point, then the button moves through loading and success. */
export function RippleButton({ onAction, label = 'Send invite', className }: RippleButtonProps) {
  const [ripples, setRipples] = useState<{ id: number; x: number; y: number; size: number }[]>([]);
  const [state, setState] = useState<State>('idle');
  const timers = useRef<number[]>([]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const click = async (e: MouseEvent<HTMLButtonElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    const size = Math.max(r.width, r.height) * 2.2;
    const id = Date.now();
    setRipples((rs) => [...rs, { id, x: e.clientX - r.left - size / 2, y: e.clientY - r.top - size / 2, size }]);
    timers.current.push(window.setTimeout(() => setRipples((rs) => rs.filter((p) => p.id !== id)), 700));
    if (state !== 'idle') return;
    setState('loading');
    await (onAction ? onAction() : new Promise((res) => timers.current.push(window.setTimeout(res, 1200))));
    setState('success');
    timers.current.push(window.setTimeout(() => setState('idle'), 1800));
  };

  return (
    <button
      onClick={click}
      aria-live="polite"
      className={cn(
        'focus-ring relative inline-flex h-11 min-w-[150px] items-center justify-center gap-2 overflow-hidden rounded-xl px-5 text-sm font-medium text-white transition-colors duration-300',
        state === 'success' ? 'bg-emerald-500' : 'bg-brand-500 hover:bg-brand-400',
        className,
      )}
    >
      <style>{css}</style>
      {ripples.map((p) => (
        <span key={p.id} aria-hidden className="pointer-events-none absolute rounded-full bg-white motion-reduce:hidden" style={{ left: p.x, top: p.y, width: p.size, height: p.size, animation: 'nui-ripple .7s ease-out forwards' }} />
      ))}
      <span key={state} className="relative flex items-center gap-2 animate-fade-in">
        {state === 'idle' && (<><Send className="h-4 w-4" /> {label}</>)}
        {state === 'loading' && (<><LoaderCircle className="h-4 w-4 animate-spin" /> Sending…</>)}
        {state === 'success' && (<><Check className="h-4 w-4" /> Sent</>)}
      </span>
    </button>
  );
}

export function RippleButtonDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      <RippleButton />
      <RippleButton label="Publish" className="bg-ink-800 hover:bg-ink-700" />
    </div>
  );
}