Web InnoventixFreeCode

Ripple Button

Original · free

Buttons with a material-style ripple from the click point.

byWeb InnoventixReact + Tailwind
btnripplebuttons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/btn-ripple.json
btn-ripple.tsx
"use client";

import {
  useCallback,
  useRef,
  useState,
  type ReactNode,
  type PointerEvent as ReactPointerEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";

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

type Variant = "solid" | "tonal" | "outline" | "ghost";
type Size = "sm" | "md" | "lg";

const variantClasses: Record<Variant, string> = {
  solid:
    "bg-indigo-600 text-white hover:bg-indigo-500 active:bg-indigo-700 shadow-lg shadow-indigo-600/25 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:active:bg-indigo-600 dark:shadow-indigo-500/20",
  tonal:
    "bg-indigo-100 text-indigo-800 hover:bg-indigo-200 active:bg-indigo-300 dark:bg-indigo-500/15 dark:text-indigo-200 dark:hover:bg-indigo-500/25 dark:active:bg-indigo-500/35",
  outline:
    "border border-slate-300 text-slate-800 hover:bg-slate-100 active:bg-slate-200 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800 dark:active:bg-slate-700/70",
  ghost:
    "text-slate-700 hover:bg-slate-100 active:bg-slate-200 dark:text-slate-200 dark:hover:bg-slate-800 dark:active:bg-slate-700/70",
};

const rippleTint: Record<Variant, string> = {
  solid: "bg-white/40",
  tonal: "bg-indigo-500/25",
  outline: "bg-slate-500/20 dark:bg-slate-300/20",
  ghost: "bg-slate-500/20 dark:bg-slate-300/20",
};

const sizeClasses: Record<Size, string> = {
  sm: "h-9 px-3.5 text-sm gap-1.5 rounded-lg",
  md: "h-11 px-5 text-sm gap-2 rounded-xl",
  lg: "h-14 px-7 text-base gap-2.5 rounded-2xl",
};

const iconSizeClasses: Record<Size, string> = {
  sm: "h-9 w-9 rounded-lg",
  md: "h-11 w-11 rounded-xl",
  lg: "h-14 w-14 rounded-2xl",
};

type RippleButtonProps = {
  children?: ReactNode;
  variant?: Variant;
  size?: Size;
  disabled?: boolean;
  loading?: boolean;
  iconOnly?: boolean;
  ariaLabel?: string;
  pressed?: boolean;
  onActivate?: () => void;
  className?: string;
};

function useRipples() {
  const [ripples, setRipples] = useState<Ripple[]>([]);
  const idRef = useRef(0);

  const spawn = useCallback(
    (el: HTMLElement, clientX: number, clientY: number) => {
      const rect = el.getBoundingClientRect();
      const x = clientX - rect.left;
      const y = clientY - rect.top;
      const size =
        Math.max(
          Math.hypot(x, y),
          Math.hypot(rect.width - x, y),
          Math.hypot(x, rect.height - y),
          Math.hypot(rect.width - x, rect.height - y),
        ) * 2;
      const id = idRef.current++;
      setRipples((prev) => [...prev, { id, x, y, size }]);
      window.setTimeout(() => {
        setRipples((prev) => prev.filter((r) => r.id !== id));
      }, 650);
    },
    [],
  );

  return { ripples, spawn };
}

function RippleButton({
  children,
  variant = "solid",
  size = "md",
  disabled = false,
  loading = false,
  iconOnly = false,
  ariaLabel,
  pressed,
  onActivate,
  className = "",
}: RippleButtonProps) {
  const { ripples, spawn } = useRipples();
  const isToggle = typeof pressed === "boolean";

  const emit = useCallback(
    (e: ReactPointerEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>) => {
      if (disabled || loading) return;
      const el = e.currentTarget;
      if ("clientX" in e && e.clientX !== 0) {
        spawn(el, e.clientX, e.clientY);
      } else {
        const rect = el.getBoundingClientRect();
        spawn(el, rect.left + rect.width / 2, rect.top + rect.height / 2);
      }
    },
    [disabled, loading, spawn],
  );

  return (
    <button
      type="button"
      aria-label={ariaLabel}
      aria-pressed={isToggle ? pressed : undefined}
      aria-busy={loading || undefined}
      disabled={disabled}
      onPointerDown={emit}
      onKeyDown={(e) => {
        if (e.key === " " || e.key === "Enter") emit(e);
      }}
      onClick={() => {
        if (!disabled && !loading) onActivate?.();
      }}
      className={[
        "brip-btn relative isolate inline-flex select-none items-center justify-center overflow-hidden font-semibold tracking-tight",
        "transition-[transform,background-color,box-shadow,color] duration-150 ease-out active:scale-[0.97]",
        "outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
        "disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 disabled:shadow-none",
        iconOnly ? iconSizeClasses[size] : sizeClasses[size],
        variantClasses[variant],
        isToggle && pressed ? "ring-2 ring-inset ring-indigo-400/60 dark:ring-indigo-300/50" : "",
        className,
      ].join(" ")}
    >
      {ripples.map((r) => (
        <span
          key={r.id}
          aria-hidden="true"
          className={`brip-ink pointer-events-none absolute z-0 rounded-full ${rippleTint[variant]}`}
          style={{
            left: r.x,
            top: r.y,
            width: r.size,
            height: r.size,
            marginLeft: -r.size / 2,
            marginTop: -r.size / 2,
          }}
        />
      ))}
      <span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
        {loading ? (
          <>
            <svg
              className="brip-spin h-4 w-4"
              viewBox="0 0 24 24"
              fill="none"
              aria-hidden="true"
            >
              <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
              <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
            </svg>
            {!iconOnly && <span>Working…</span>}
          </>
        ) : (
          children
        )}
      </span>
    </button>
  );
}

function HeartIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      className={`h-5 w-5 transition-transform duration-200 ${filled ? "scale-110" : "scale-100"}`}
      viewBox="0 0 24 24"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M5 12h14M13 6l6 6-6 6" />
    </svg>
  );
}

function BoltIcon() {
  return (
    <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M13 2 4.5 13.5H11l-1 8.5L19.5 10H13l1-8Z" />
    </svg>
  );
}

function Row({ label, children }: { label: string; children: ReactNode }) {
  return (
    <div className="flex flex-col gap-3 border-t border-slate-200/70 py-6 first:border-t-0 dark:border-slate-800/70 sm:flex-row sm:items-center">
      <span className="w-28 shrink-0 font-mono text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500">
        {label}
      </span>
      <div className="flex flex-wrap items-center gap-3">{children}</div>
    </div>
  );
}

export default function BtnRipple() {
  const [liked, setLiked] = useState(false);
  const [notify, setNotify] = useState(true);
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = useCallback(() => {
    setSubmitting(true);
    window.setTimeout(() => setSubmitting(false), 1800);
  }, []);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-50 sm:px-10">
      <style>{`
        @keyframes brip-expand {
          0%   { transform: scale(0); opacity: 0.55; }
          100% { transform: scale(1); opacity: 0; }
        }
        @keyframes brip-rotate {
          to { transform: rotate(360deg); }
        }
        .brip-ink { transform: scale(0); animation: brip-expand 0.62s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; }
        .brip-spin { animation: brip-rotate 0.75s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .brip-ink { animation-duration: 0.01ms; opacity: 0; }
          .brip-spin { animation-duration: 1.4s; }
          .brip-btn { transition-duration: 0.01ms; }
          .brip-btn:active { transform: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
      />

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-10">
          <p className="mb-3 font-mono text-xs uppercase tracking-[0.25em] text-indigo-500 dark:text-indigo-400">
            FreeCode / buttons
          </p>
          <h2 className="text-3xl font-bold tracking-tight sm:text-4xl">Ripple buttons</h2>
          <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
            A material-style ink ripple that emanates from the exact point you press — pointer or
            keyboard. Tactile, accessible, and self-contained.
          </p>
        </header>

        <div className="rounded-3xl border border-slate-200 bg-white/70 px-6 py-2 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50 sm:px-8">
          <Row label="Variants">
            <RippleButton variant="solid" onActivate={() => undefined}>
              Deploy build
            </RippleButton>
            <RippleButton variant="tonal" onActivate={() => undefined}>
              Save draft
            </RippleButton>
            <RippleButton variant="outline" onActivate={() => undefined}>
              Preview
            </RippleButton>
            <RippleButton variant="ghost" onActivate={() => undefined}>
              Cancel
            </RippleButton>
          </Row>

          <Row label="Sizes">
            <RippleButton size="sm" variant="solid">
              Small
            </RippleButton>
            <RippleButton size="md" variant="solid">
              Medium
            </RippleButton>
            <RippleButton size="lg" variant="solid">
              Large
            </RippleButton>
          </Row>

          <Row label="With icons">
            <RippleButton variant="solid">
              <BoltIcon />
              Upgrade to Pro
            </RippleButton>
            <RippleButton variant="tonal">
              Continue
              <ArrowIcon />
            </RippleButton>
            <RippleButton variant="outline" iconOnly size="md" ariaLabel="Add to favorites">
              <HeartIcon filled={false} />
            </RippleButton>
          </Row>

          <Row label="States">
            <RippleButton
              variant="solid"
              loading={submitting}
              onActivate={handleSubmit}
            >
              {submitting ? "Working…" : "Submit order"}
            </RippleButton>
            <RippleButton variant="solid" disabled>
              Sold out
            </RippleButton>
            <RippleButton variant="outline" disabled>
              Unavailable
            </RippleButton>
          </Row>

          <Row label="Toggles">
            <RippleButton
              variant={liked ? "solid" : "outline"}
              pressed={liked}
              onActivate={() => setLiked((v) => !v)}
              ariaLabel={liked ? "Remove from favorites" : "Add to favorites"}
              iconOnly
            >
              <HeartIcon filled={liked} />
            </RippleButton>
            <RippleButton
              variant={notify ? "tonal" : "ghost"}
              pressed={notify}
              onActivate={() => setNotify((v) => !v)}
            >
              {notify ? "Notifications on" : "Notifications off"}
            </RippleButton>
          </Row>
        </div>

        <p className="mt-6 text-center font-mono text-xs text-slate-400 dark:text-slate-600">
          Press anywhere on a button — the ripple starts where you touch.
        </p>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →