);
}
```
### Props
- `children` (`React.ReactNode`)
The label of the pressable surface. It is painted above the ripple layer, so the bloom never washes it out.
- `onPress` (`() => void`) — default: `undefined`
Fired on click, which means pointer and keyboard activation both route through the same native path.
- `disabled` (`boolean`) — default: `false`
Disables the button and refuses to spawn ripples, so a dead control never gives live feedback.
- `max` (`number`) — default: `4`
Ceiling on simultaneous blooms. A hammered key evicts the oldest instead of growing the DOM without limit.
- `tintClassName` (`string`) — default: `"bg-stone-800/15 dark:bg-white/20"`
The bloom fill. Swap it when the surface is dark, inverted, or tinted with a brand color.
- `className` (`string`) — default: `""`
Appended last, so the caller's radius, height and padding win. The clip layer reads border-radius with rounded-[inherit], so overriding the radius still clips correctly.
### Behavior notes
- A tap released in forty milliseconds still gets a whole bloom: the fade cannot begin until the ripple has been visible for its minimum window, so the fastest presses are the ones most implementations swallow and this one does not.
- The bloom is spawned at the pointer's coordinates inside the element rect and scaled to the distance of the farthest corner, so a press on an edge fills the surface instead of stopping short of the opposite side.
- Nothing that moves is a layout property: the ripple is a fixed 40px patch, absolutely positioned inside an aria-hidden overlay, driven only by transform and opacity, so no press can shift the content sitting above it.
- Pointer capture, lost capture, pointer cancel, blur, and tab hide all release through one path, so dragging off the control, scrolling the list out from under a finger, or switching tabs mid-press never strands a ripple on screen.
- Space and Enter spawn from the element's centre and release on key up, so keyboard activation is acknowledged exactly like a finger, while the overlay stays aria-hidden and the button announces itself once rather than once per bloom.
- Under prefers-reduced-motion the patch arrives already at full size and only fades, so the press is still confirmed and only the travel is skipped.
### Source (`components/interior/ripple.tsx`)
```tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const BLOOM = { duration: 0.5, ease: "linear" } as const;
const BASE = 40;
export type RippleSpec = {
id: number;
x: number;
y: number;
scale: number;
released: boolean;
};
export type UseRippleOptions = {
disabled?: boolean;
max?: number;
minVisible?: number;
fade?: number;
};
export function useRipple({
disabled = false,
max = 4,
minVisible = 220,
fade = 320,
}: UseRippleOptions = {}) {
const [ripples, setRipples] = useState([]);
const list = useRef([]);
const seq = useRef(0);
const born = useRef(new Map());
const timers = useRef(new Map[]>());
const pointers = useRef(new Map());
const keyed = useRef(null);
const commit = useCallback((next: RippleSpec[]) => {
list.current = next;
setRipples(next);
}, []);
const forget = useCallback((id: number) => {
timers.current.get(id)?.forEach(clearTimeout);
timers.current.delete(id);
born.current.delete(id);
}, []);
const spawn = useCallback(
(el: HTMLElement, clientX?: number, clientY?: number) => {
const rect = el.getBoundingClientRect();
const x = Math.round(
clientX === undefined ? rect.width / 2 : clientX - rect.left,
);
const y = Math.round(
clientY === undefined ? rect.height / 2 : clientY - rect.top,
);
const reach = 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),
);
let next = list.current;
while (next.length >= max) {
forget(next[0].id);
next = next.slice(1);
}
const id = (seq.current += 1);
born.current.set(id, performance.now());
commit([
...next,
{
id,
x,
y,
scale: Math.round((reach * 200) / BASE) / 100,
released: false,
},
]);
return id;
},
[commit, forget, max],
);
const release = useCallback(
(id: number) => {
if (timers.current.has(id)) return;
if (!list.current.some((r) => r.id === id)) return;
const wait = Math.max(
0,
minVisible - (performance.now() - (born.current.get(id) ?? 0)),
);
const start = setTimeout(() => {
commit(
list.current.map((r) => (r.id === id ? { ...r, released: true } : r)),
);
}, wait);
const drop = setTimeout(() => {
forget(id);
commit(list.current.filter((r) => r.id !== id));
}, wait + fade);
timers.current.set(id, [start, drop]);
},
[commit, fade, forget, minVisible],
);
const releaseAll = useCallback(() => {
pointers.current.forEach((id) => release(id));
pointers.current.clear();
if (keyed.current !== null) {
release(keyed.current);
keyed.current = null;
}
}, [release]);
const endPointer = useCallback(
(pointerId: number) => {
const id = pointers.current.get(pointerId);
if (id === undefined) return;
pointers.current.delete(pointerId);
release(id);
},
[release],
);
useEffect(() => {
const bail = () => releaseAll();
const onVisibility = () => document.hidden && releaseAll();
window.addEventListener("blur", bail);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", bail);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [releaseAll]);
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach((set) => set.forEach(clearTimeout));
pending.clear();
};
}, []);
const bind = {
onPointerDown: (e: React.PointerEvent) => {
if (disabled) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
if (pointers.current.has(e.pointerId)) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
pointers.current.set(
e.pointerId,
spawn(e.currentTarget, e.clientX, e.clientY),
);
},
onPointerUp: (e: React.PointerEvent) => endPointer(e.pointerId),
onPointerCancel: (e: React.PointerEvent) =>
endPointer(e.pointerId),
onLostPointerCapture: (e: React.PointerEvent) =>
endPointer(e.pointerId),
onKeyDown: (e: React.KeyboardEvent) => {
if (disabled || e.repeat || keyed.current !== null) return;
if (e.key !== " " && e.key !== "Enter") return;
keyed.current = spawn(e.currentTarget);
},
onKeyUp: (e: React.KeyboardEvent) => {
if (keyed.current === null) return;
if (e.key !== " " && e.key !== "Enter" && e.key !== "Escape") return;
release(keyed.current);
keyed.current = null;
},
onBlur: () => releaseAll(),
};
return { bind, ripples, fadeDuration: fade / 1000 };
}
export type RippleProps = {
children: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
max?: number;
tintClassName?: string;
className?: string;
};
export function Ripple({
children,
onPress,
disabled = false,
max = 4,
tintClassName = "bg-stone-800/15 dark:bg-white/20",
className = "",
}: RippleProps) {
const { bind, ripples, fadeDuration } = useRipple({ disabled, max });
const reduced = useReducedMotion();
return (
);
}
```