## Ripple — Action Feedback Touch feedback from the pointer origin. Docs: https://www.interior.dev/docs/ripple Reference: https://www.interior.dev/reference/ripple License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/ripple.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { Ripple, useRipple } from "@/components/interior/ripple"; export function PinPad({ onDigit }: { onDigit: (d: string) => void }) { return (
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => ( onDigit(d)} className="h-12 w-full font-mono"> {d} ))}
); } export function TrackRow({ title, onPlay }: { title: string; onPlay: () => void }) { const { bind, ripples, fadeDuration } = useRipple({ max: 2 }); return (
{ripples.map((r) => ( ))} {title}
); } ``` ### 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 ( ); } ```