Ripple Click Effect
A Material-style ripple from the exact press point that grows to the farthest corner, holds while you hold and fades on release. A button, plus a wrapper for any card or list row. No dependencies.
DOM + CSS0 deps1 file · 9 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef } from "react";
import type {
ComponentPropsWithoutRef,
RefObject,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
export type RippleTone = "light" | "dark";
type RippleOptions = {
color: RippleTone;
rippleColor?: string;
duration: number;
centered: boolean;
disabled: boolean;
};
// Interactive things a key press can start a ripple from.
const INTERACTIVE =
"button, a[href], summary, [role=button], [role=menuitem], [role=option], [role=tab], [role=switch], [role=checkbox]";
// rippleColor reaches a style attribute, so only plain CSS colour syntax is allowed through.
const SAFE_COLOR = /^[#a-zA-Z0-9(),.%\s/-]+$/;
const MAX_RIPPLES = 14;
const MIN_SHOW = 110;
const GROW_EASE = "cubic-bezier(0.2, 0, 0, 1)";
const noop = () => {};
/** Spawn one ripple inside `layer`. Returns the function that releases (fades) it. */
function spawn(layer: HTMLElement, o: RippleOptions, point: { x: number; y: number } | null): () => void {
const w = layer.offsetWidth;
const h = layer.offsetHeight;
if (!w || !h) return noop;
// Layout pixels, not screen pixels: a transform-scaled parent shrinks getBoundingClientRect.
const rect = layer.getBoundingClientRect();
const k = rect.width ? w / rect.width : 1;
let x = w / 2;
let y = h / 2;
if (point && !o.centered) {
x = Math.min(w, Math.max(0, (point.x - rect.left) * k));
y = Math.min(h, Math.max(0, (point.y - rect.top) * k));
}
const custom = o.rippleColor && SAFE_COLOR.test(o.rippleColor) ? o.rippleColor : null;
const base = custom ?? (o.color === "dark" ? "#000" : "#fff");
const strength = custom ? 30 : o.color === "dark" ? 24 : 34;
const tint = (pct: number) => "color-mix(in srgb, " + base + " " + pct.toFixed(1) + "%, transparent)";
const reduce = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const el = document.createElement("span");
el.style.position = "absolute";
el.style.opacity = "0";
el.style.pointerEvents = "none";
let fadeIn: Animation;
if (reduce) {
// Calm equivalent: the whole surface washes in and out, nothing travels.
el.style.inset = "0";
el.style.background = tint(strength * 0.7);
fadeIn = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 140, easing: "ease-out", fill: "forwards" });
} else {
// Twice the distance to the farthest corner: the disc always covers the whole surface.
const size = Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) * 2;
el.style.left = x - size / 2 + "px";
el.style.top = y - size / 2 + "px";
el.style.width = size + "px";
el.style.height = size + "px";
el.style.borderRadius = "50%";
el.style.willChange = "transform, opacity";
// A soft body with a slightly brighter leading rim, so it reads as a wave and not a flat disc.
el.style.background =
"radial-gradient(circle closest-side, " +
tint(strength * 0.82) + " 0%, " +
tint(strength * 0.82) + " 66%, " +
tint(strength * 1.2) + " 93%, " +
tint(0) + " 100%)";
el.animate([{ transform: "scale(0)" }, { transform: "scale(1)" }], {
duration: o.duration,
easing: GROW_EASE,
fill: "forwards",
});
fadeIn = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 40, easing: "linear", fill: "forwards" });
}
while (layer.childElementCount >= MAX_RIPPLES) layer.firstElementChild?.remove();
layer.appendChild(el);
let released = false;
const born = performance.now();
const fade = () => {
const now = Number(getComputedStyle(el).opacity);
fadeIn.cancel();
const out = el.animate([{ opacity: Number.isFinite(now) ? now : 1 }, { opacity: 0 }], {
duration: reduce ? 320 : Math.max(320, o.duration * 0.62),
easing: "ease-out",
fill: "forwards",
});
out.onfinish = () => el.remove();
};
return () => {
if (released) return;
released = true;
// A tap that lifts instantly still shows a full ripple: hold at least MIN_SHOW ms before fading.
const wait = MIN_SHOW - (performance.now() - born);
if (wait > 0) window.setTimeout(fade, wait);
else fade();
};
}
function useRipple(layerRef: RefObject<HTMLSpanElement | null>, o: RippleOptions) {
const opts = useRef(o);
const live = useRef(new Set<() => void>());
const keyEnd = useRef<(() => void) | null>(null);
useEffect(() => {
opts.current = o;
});
useEffect(() => {
const set = live.current;
return () => {
set.forEach((release) => release());
set.clear();
};
}, []);
function onPointerDown(e: ReactPointerEvent<HTMLElement>) {
const layer = layerRef.current;
if (!layer || opts.current.disabled) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const release = spawn(layer, opts.current, { x: e.clientX, y: e.clientY });
const end = () => {
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
live.current.delete(end);
release();
};
live.current.add(end);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", end);
}
function onKeyDown(e: ReactKeyboardEvent<HTMLElement>) {
const layer = layerRef.current;
if (!layer || opts.current.disabled || e.repeat) return;
if (e.key !== "Enter" && e.key !== " ") return;
const target = e.target instanceof Element ? e.target.closest(INTERACTIVE) : null;
if (!target) return;
const r = target.getBoundingClientRect();
keyEnd.current?.();
const release = spawn(layer, opts.current, { x: r.left + r.width / 2, y: r.top + r.height / 2 });
const end = () => {
live.current.delete(end);
keyEnd.current = null;
release();
};
live.current.add(end);
keyEnd.current = end;
}
function release() {
keyEnd.current?.();
}
return { onPointerDown, onKeyDown, release };
}
const LAYER = "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]";
export type RippleButtonProps = Omit<ComponentPropsWithoutRef<"button">, "color"> & {
label?: string;
/** "light" ripples a dark/colored button, "dark" ripples a light one. */
color?: RippleTone;
/** Any CSS colour; overrides `color` (e.g. "oklch(0.7 0.2 250)"). */
rippleColor?: string;
/** Milliseconds the ripple takes to reach the far corner. */
duration?: number;
/** Always start from the centre instead of the pointer. */
centered?: boolean;
};
export function RippleButton({
label = "Click me",
color = "light",
rippleColor,
duration = 700,
centered = false,
className = "",
children,
disabled,
onPointerDown,
onKeyDown,
onKeyUp,
onBlur,
...rest
}: RippleButtonProps) {
const layerRef = useRef<HTMLSpanElement>(null);
const rp = useRipple(layerRef, { color, rippleColor, duration, centered, disabled: !!disabled });
return (
<button
{...rest}
disabled={disabled}
className={
"relative overflow-hidden rounded-xl bg-red-500 px-8 py-4 font-semibold text-white transition-transform duration-150 ease-out active:scale-[0.985] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-blue-500 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100 " +
className
}
onPointerDown={(e) => {
onPointerDown?.(e);
rp.onPointerDown(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
rp.onKeyDown(e);
}}
onKeyUp={(e) => {
onKeyUp?.(e);
rp.release();
}}
onBlur={(e) => {
onBlur?.(e);
rp.release();
}}
>
{children ?? label}
<span ref={layerRef} aria-hidden="true" className={LAYER} />
</button>
);
}
export type RippleProps = Omit<ComponentPropsWithoutRef<"div">, "color"> & {
as?: "div" | "span" | "li" | "section" | "article";
color?: RippleTone;
rippleColor?: string;
duration?: number;
centered?: boolean;
disabled?: boolean;
};
/**
* The same ripple on any surface: a card, a list row, an image tile. Pointer presses ripple from the
* pointer; Enter or Space on a focusable control inside (or on the surface itself, with role="button"
* and tabIndex) ripples from that control. Give the surface its own radius; the ripple follows it.
*/
export function Ripple({
as = "div",
color = "light",
rippleColor,
duration = 700,
centered = false,
disabled = false,
className = "",
children,
onPointerDown,
onKeyDown,
onKeyUp,
onBlur,
...rest
}: RippleProps) {
const layerRef = useRef<HTMLSpanElement>(null);
const rp = useRipple(layerRef, { color, rippleColor, duration, centered, disabled });
const Tag = as as "div";
return (
<Tag
{...rest}
className={"relative " + className}
onPointerDown={(e) => {
onPointerDown?.(e);
rp.onPointerDown(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
rp.onKeyDown(e);
}}
onKeyUp={(e) => {
onKeyUp?.(e);
rp.release();
}}
onBlur={(e) => {
onBlur?.(e);
rp.release();
}}
>
{children}
<span ref={layerRef} aria-hidden="true" className={LAYER} />
</Tag>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
RippleButton keeps its props: label and color (light or dark ripple) still work, and the default is the same red button with a white ripple. The ripple starts at the exact press point and is sized to twice the distance to the farthest corner, so a corner press still covers everything. It starts on pointer down, keeps expanding while the button is held, and fades when the pointer is released or cancelled, so a scroll that steals a touch fades it cleanly. Ripples are plain elements animated with the Web Animations API, one per press, so rapid presses overlap instead of restarting each other, and at most fourteen live at once. The pointer is mapped to layout pixels, so it stays correct inside a scaled preview. Enter or Space on the focused button ripples from its centre and holds until the key is released. The body is a soft disc with a slightly brighter rim. New optional props: rippleColor (any CSS colour), duration (grow time in ms, default 700) and centered. The Ripple component applies the same effect to any element (as div, span, li, section or article): give it a radius and the ripple follows it, press anywhere on it, and Enter or Space on a focusable control inside it ripples from that control. Passing your own onPointerDown, onKeyDown, onKeyUp or onBlur still works; they run first. Tailwind classes passed to RippleButton append to the defaults, so override the background with an important modifier such as !bg-white.
Curator’s note
Rebuilt in October without Framer Motion: the ripple now holds while you hold and fades on release, works from the keyboard, and lives in a Ripple wrapper for any surface as well as the button.